notification preferances added approver performance api altererd resume added for initiator also
This commit is contained in:
parent
1bebf3a46a
commit
7358c3ff30
@ -59,7 +59,7 @@ export function AIConfig() {
|
|||||||
openaiApiKey: configMap['OPENAI_API_KEY'] || '',
|
openaiApiKey: configMap['OPENAI_API_KEY'] || '',
|
||||||
geminiApiKey: configMap['GEMINI_API_KEY'] || '',
|
geminiApiKey: configMap['GEMINI_API_KEY'] || '',
|
||||||
aiRemarkGeneration: configMap['AI_REMARK_GENERATION_ENABLED'] === 'true',
|
aiRemarkGeneration: configMap['AI_REMARK_GENERATION_ENABLED'] === 'true',
|
||||||
maxRemarkChars: parseInt(configMap['AI_REMARK_MAX_CHARACTERS'] || '500')
|
maxRemarkChars: parseInt(configMap['AI_MAX_REMARK_LENGTH'] || '2000')
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load AI configurations:', error);
|
console.error('Failed to load AI configurations:', error);
|
||||||
@ -81,7 +81,7 @@ export function AIConfig() {
|
|||||||
updateConfiguration('OPENAI_API_KEY', config.openaiApiKey),
|
updateConfiguration('OPENAI_API_KEY', config.openaiApiKey),
|
||||||
updateConfiguration('GEMINI_API_KEY', config.geminiApiKey),
|
updateConfiguration('GEMINI_API_KEY', config.geminiApiKey),
|
||||||
updateConfiguration('AI_REMARK_GENERATION_ENABLED', config.aiRemarkGeneration.toString()),
|
updateConfiguration('AI_REMARK_GENERATION_ENABLED', config.aiRemarkGeneration.toString()),
|
||||||
updateConfiguration('AI_REMARK_MAX_CHARACTERS', config.maxRemarkChars.toString())
|
updateConfiguration('AI_MAX_REMARK_LENGTH', config.maxRemarkChars.toString())
|
||||||
]);
|
]);
|
||||||
|
|
||||||
toast.success('AI configuration saved successfully');
|
toast.success('AI configuration saved successfully');
|
||||||
|
|||||||
@ -26,19 +26,19 @@ export function AIParameters({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="max-remark-chars" className="text-sm font-medium">
|
<Label htmlFor="max-remark-chars" className="text-sm font-medium">
|
||||||
Maximum Remark Characters
|
Maximum Remark Length
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="max-remark-chars"
|
id="max-remark-chars"
|
||||||
type="number"
|
type="number"
|
||||||
min="100"
|
min="500"
|
||||||
max="2000"
|
max="5000"
|
||||||
value={maxRemarkChars}
|
value={maxRemarkChars}
|
||||||
onChange={(e) => onMaxRemarkCharsChange(parseInt(e.target.value) || 500)}
|
onChange={(e) => onMaxRemarkCharsChange(parseInt(e.target.value) || 2000)}
|
||||||
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Maximum character limit for AI-generated conclusion remarks (100-2000 characters)
|
Maximum character length for AI-generated conclusion remarks (500-5000 characters)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -258,9 +258,9 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter out notification rules, dashboard layout categories, and allow external sharing
|
// Filter out dashboard layout category and specific config keys
|
||||||
const excludedCategories = ['NOTIFICATION_RULES', 'DASHBOARD_LAYOUT'];
|
const excludedCategories = ['DASHBOARD_LAYOUT'];
|
||||||
const excludedConfigKeys = ['ALLOW_EXTERNAL_SHARING'];
|
const excludedConfigKeys = ['ALLOW_EXTERNAL_SHARING', 'NOTIFICATION_BATCH_DELAY_MS', 'AI_REMARK_MAX_CHARACTERS'];
|
||||||
const filteredConfigurations = configurations.filter(
|
const filteredConfigurations = configurations.filter(
|
||||||
config => !excludedCategories.includes(config.configCategory) &&
|
config => !excludedCategories.includes(config.configCategory) &&
|
||||||
!excludedConfigKeys.includes(config.configKey)
|
!excludedConfigKeys.includes(config.configKey)
|
||||||
|
|||||||
192
src/components/settings/NotificationPreferences.tsx
Normal file
192
src/components/settings/NotificationPreferences.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Bell, Mail, MessageSquare, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import { getNotificationPreferences, updateNotificationPreferences, NotificationPreferences } from '@/services/userPreferenceApi';
|
||||||
|
|
||||||
|
export function NotificationPreferencesCard() {
|
||||||
|
const [preferences, setPreferences] = useState<NotificationPreferences>({
|
||||||
|
emailNotificationsEnabled: true,
|
||||||
|
pushNotificationsEnabled: true,
|
||||||
|
inAppNotificationsEnabled: true
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPreferences();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getNotificationPreferences();
|
||||||
|
setPreferences(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[NotificationPreferences] Failed to load preferences:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to load notification preferences');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (key: keyof NotificationPreferences, value: boolean) => {
|
||||||
|
try {
|
||||||
|
setUpdating(key);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
const updateData = { [key]: value };
|
||||||
|
const updated = await updateNotificationPreferences(updateData);
|
||||||
|
|
||||||
|
setPreferences(updated);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const prefName = key === 'emailNotificationsEnabled' ? 'Email'
|
||||||
|
: key === 'pushNotificationsEnabled' ? 'Push'
|
||||||
|
: 'In-App';
|
||||||
|
setSuccessMessage(`${prefName} notifications ${value ? 'enabled' : 'disabled'}`);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[NotificationPreferences] Failed to update preference:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to update notification preference');
|
||||||
|
// Revert the UI change
|
||||||
|
loadPreferences();
|
||||||
|
} finally {
|
||||||
|
setUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md">
|
||||||
|
<CardContent className="p-12 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
|
||||||
|
<Bell className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">Notification Preferences</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">Control how you receive notifications</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
|
||||||
|
<p className="text-sm text-green-800 font-medium">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
|
||||||
|
<p className="text-sm text-red-800 font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Notifications */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white rounded-md shadow-sm">
|
||||||
|
<Mail className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
|
||||||
|
Email Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">Receive notifications via email</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'emailNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="email-notifications"
|
||||||
|
checked={preferences.emailNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('emailNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'emailNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Push Notifications */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white rounded-md shadow-sm">
|
||||||
|
<Bell className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="push-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
|
||||||
|
Push Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">Receive browser push notifications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'pushNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="push-notifications"
|
||||||
|
checked={preferences.pushNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('pushNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'pushNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In-App Notifications */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white rounded-md shadow-sm">
|
||||||
|
<MessageSquare className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="inapp-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
|
||||||
|
In-App Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">Show notifications within the application</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'inAppNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="inapp-notifications"
|
||||||
|
checked={preferences.inAppNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('inAppNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'inAppNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-200">
|
||||||
|
<p className="text-xs text-gray-500 text-center">
|
||||||
|
These preferences control which notification channels you receive alerts through.
|
||||||
|
You'll still receive critical notifications regardless of these settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
224
src/components/settings/NotificationPreferencesModal.tsx
Normal file
224
src/components/settings/NotificationPreferencesModal.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Bell, Mail, MessageSquare, Loader2, CheckCircle, AlertCircle, Settings } from 'lucide-react';
|
||||||
|
import { getNotificationPreferences, updateNotificationPreferences, NotificationPreferences } from '@/services/userPreferenceApi';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
interface NotificationPreferencesModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationPreferencesModal({ open, onClose }: NotificationPreferencesModalProps) {
|
||||||
|
const [preferences, setPreferences] = useState<NotificationPreferences>({
|
||||||
|
emailNotificationsEnabled: true,
|
||||||
|
pushNotificationsEnabled: true,
|
||||||
|
inAppNotificationsEnabled: true
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadPreferences();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getNotificationPreferences();
|
||||||
|
setPreferences(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[NotificationPreferences] Failed to load preferences:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to load notification preferences');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (key: keyof NotificationPreferences, value: boolean) => {
|
||||||
|
try {
|
||||||
|
setUpdating(key);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
const updateData = { [key]: value };
|
||||||
|
const updated = await updateNotificationPreferences(updateData);
|
||||||
|
|
||||||
|
setPreferences(updated);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const prefName = key === 'emailNotificationsEnabled' ? 'Email'
|
||||||
|
: key === 'pushNotificationsEnabled' ? 'Push'
|
||||||
|
: 'In-App';
|
||||||
|
setSuccessMessage(`${prefName} notifications ${value ? 'enabled' : 'disabled'}`);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[NotificationPreferences] Failed to update preference:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to update notification preference');
|
||||||
|
// Revert the UI change
|
||||||
|
loadPreferences();
|
||||||
|
} finally {
|
||||||
|
setUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg">
|
||||||
|
<Settings className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-semibold">Notification Preferences</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm">
|
||||||
|
Customize how you receive notifications for workflow updates
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-12 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-md animate-in fade-in slide-in-from-top-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
|
||||||
|
<p className="text-sm text-green-800 font-medium">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md animate-in fade-in slide-in-from-top-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
|
||||||
|
<p className="text-sm text-red-800 font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Notifications */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border border-gray-200 hover:border-gray-300 transition-all">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-white rounded-lg shadow-sm">
|
||||||
|
<Mail className="w-6 h-6 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email-notifications-modal" className="text-base font-semibold text-gray-900 cursor-pointer">
|
||||||
|
Email Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Receive important updates and alerts via email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'emailNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="email-notifications-modal"
|
||||||
|
checked={preferences.emailNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('emailNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'emailNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Push Notifications */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border border-gray-200 hover:border-gray-300 transition-all">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-white rounded-lg shadow-sm">
|
||||||
|
<Bell className="w-6 h-6 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="push-notifications-modal" className="text-base font-semibold text-gray-900 cursor-pointer">
|
||||||
|
Push Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Get instant browser notifications for real-time updates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'pushNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="push-notifications-modal"
|
||||||
|
checked={preferences.pushNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('pushNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'pushNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In-App Notifications */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border border-gray-200 hover:border-gray-300 transition-all">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-white rounded-lg shadow-sm">
|
||||||
|
<MessageSquare className="w-6 h-6 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="inapp-notifications-modal" className="text-base font-semibold text-gray-900 cursor-pointer">
|
||||||
|
In-App Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
View notifications in the notification center
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'inAppNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="inapp-notifications-modal"
|
||||||
|
checked={preferences.inAppNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('inAppNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'inAppNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">
|
||||||
|
<span className="font-semibold">Note:</span> These settings control your notification preferences across all channels.
|
||||||
|
Critical system alerts and urgent notifications may still be delivered regardless of these settings to ensure important information reaches you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
176
src/components/settings/NotificationPreferencesSimple.tsx
Normal file
176
src/components/settings/NotificationPreferencesSimple.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Bell, Mail, MessageSquare, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import { getNotificationPreferences, updateNotificationPreferences, NotificationPreferences } from '@/services/userPreferenceApi';
|
||||||
|
|
||||||
|
export function NotificationPreferencesSimple() {
|
||||||
|
const [preferences, setPreferences] = useState<NotificationPreferences>({
|
||||||
|
emailNotificationsEnabled: true,
|
||||||
|
pushNotificationsEnabled: true,
|
||||||
|
inAppNotificationsEnabled: true
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPreferences();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getNotificationPreferences();
|
||||||
|
setPreferences(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[NotificationPreferences] Failed to load preferences:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to load notification preferences');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (key: keyof NotificationPreferences, value: boolean) => {
|
||||||
|
try {
|
||||||
|
setUpdating(key);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
const updateData = { [key]: value };
|
||||||
|
const updated = await updateNotificationPreferences(updateData);
|
||||||
|
|
||||||
|
setPreferences(updated);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const prefName = key === 'emailNotificationsEnabled' ? 'Email'
|
||||||
|
: key === 'pushNotificationsEnabled' ? 'Push'
|
||||||
|
: 'In-App';
|
||||||
|
setSuccessMessage(`${prefName} notifications ${value ? 'enabled' : 'disabled'}`);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[NotificationPreferences] Failed to update preference:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to update notification preference');
|
||||||
|
// Revert the UI change
|
||||||
|
loadPreferences();
|
||||||
|
} finally {
|
||||||
|
setUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
|
||||||
|
<p className="text-sm text-green-800 font-medium">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
|
||||||
|
<p className="text-sm text-red-800 font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Notifications */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white rounded-md shadow-sm">
|
||||||
|
<Mail className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
|
||||||
|
Email Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">Receive notifications via email</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'emailNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="email-notifications"
|
||||||
|
checked={preferences.emailNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('emailNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'emailNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Push Notifications */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white rounded-md shadow-sm">
|
||||||
|
<Bell className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="push-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
|
||||||
|
Push Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">Receive browser push notifications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'pushNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="push-notifications"
|
||||||
|
checked={preferences.pushNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('pushNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'pushNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In-App Notifications */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200 hover:border-gray-300 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white rounded-md shadow-sm">
|
||||||
|
<MessageSquare className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="inapp-notifications" className="text-sm font-semibold text-gray-900 cursor-pointer">
|
||||||
|
In-App Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">Show notifications within the application</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{updating === 'inAppNotificationsEnabled' && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id="inapp-notifications"
|
||||||
|
checked={preferences.inAppNotificationsEnabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle('inAppNotificationsEnabled', checked)}
|
||||||
|
disabled={updating === 'inAppNotificationsEnabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-gray-200">
|
||||||
|
<p className="text-xs text-gray-500 text-center">
|
||||||
|
These preferences control which notification channels you receive alerts through.
|
||||||
|
You'll still receive critical notifications regardless of these settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -639,9 +639,9 @@ export function ApprovalStepCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skip Approver Button - Only show for initiator on pending/in-review/paused levels */}
|
{/* Skip Approver Button - Only show for initiator on pending/in-review levels (not when paused) */}
|
||||||
{/* When paused, initiator can skip the approver which will negate the pause */}
|
{/* User must resume first before skipping */}
|
||||||
{isInitiator && (isActive || isPaused || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
|
{isInitiator && !isPaused && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
|
||||||
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -640,26 +640,36 @@ export function useRequestDetails(
|
|||||||
if (!requestIdentifier || !apiRequest) return;
|
if (!requestIdentifier || !apiRequest) return;
|
||||||
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (!socket) return;
|
if (!socket) {
|
||||||
|
console.warn('[useRequestDetails] Socket not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useRequestDetails] Setting up socket listener for request:', apiRequest.requestId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler: Request updated by another user
|
* Handler: Request updated by another user
|
||||||
* Silently refresh to show latest changes
|
* Silently refresh to show latest changes
|
||||||
*/
|
*/
|
||||||
const handleRequestUpdated = (data: any) => {
|
const handleRequestUpdated = (data: any) => {
|
||||||
|
console.log('[useRequestDetails] 📡 Received request:updated event:', data);
|
||||||
// Verify this update is for the current request
|
// Verify this update is for the current request
|
||||||
if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) {
|
if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) {
|
||||||
console.log('[useRequestDetails] 🔄 Request updated remotely, refreshing silently...');
|
console.log('[useRequestDetails] 🔄 Request updated remotely, refreshing silently...');
|
||||||
// Silent refresh - no loading state, no user interruption
|
// Silent refresh - no loading state, no user interruption
|
||||||
refreshDetails();
|
refreshDetails();
|
||||||
|
} else {
|
||||||
|
console.log('[useRequestDetails] ⚠️ Event for different request, ignoring');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register listener
|
// Register listener
|
||||||
socket.on('request:updated', handleRequestUpdated);
|
socket.on('request:updated', handleRequestUpdated);
|
||||||
|
console.log('[useRequestDetails] ✅ Registered listener for request:updated');
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[useRequestDetails] 🧹 Cleaning up socket listener');
|
||||||
socket.off('request:updated', handleRequestUpdated);
|
socket.off('request:updated', handleRequestUpdated);
|
||||||
};
|
};
|
||||||
}, [requestIdentifier, apiRequest, refreshDetails]);
|
}, [requestIdentifier, apiRequest, refreshDetails]);
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
* This is a refactored version that uses modular components, hooks, and utilities.
|
* This is a refactored version that uses modular components, hooks, and utilities.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
@ -19,9 +18,6 @@ import { ApproverPerformanceRequestList } from './components/ApproverPerformance
|
|||||||
import { useApproverPerformanceFilters } from './hooks/useApproverPerformanceFilters';
|
import { useApproverPerformanceFilters } from './hooks/useApproverPerformanceFilters';
|
||||||
import { useApproverPerformanceData } from './hooks/useApproverPerformanceData';
|
import { useApproverPerformanceData } from './hooks/useApproverPerformanceData';
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { calculateApproverStats } from './utils/statsCalculations';
|
|
||||||
|
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
export function ApproverPerformance() {
|
export function ApproverPerformance() {
|
||||||
@ -47,10 +43,7 @@ export function ApproverPerformance() {
|
|||||||
itemsPerPage
|
itemsPerPage
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate stats from filtered data
|
// All stats come from backend (data.approverStats) - no frontend calculation needed
|
||||||
const calculatedStats = useMemo(() => {
|
|
||||||
return calculateApproverStats(data.allFilteredRequests);
|
|
||||||
}, [data.allFilteredRequests]);
|
|
||||||
|
|
||||||
if (!approverId) {
|
if (!approverId) {
|
||||||
return <ApproverPerformanceEmpty />;
|
return <ApproverPerformanceEmpty />;
|
||||||
@ -69,16 +62,19 @@ export function ApproverPerformance() {
|
|||||||
{data.approverStats && (
|
{data.approverStats && (
|
||||||
<ApproverPerformanceStatsCards
|
<ApproverPerformanceStatsCards
|
||||||
approverStats={data.approverStats}
|
approverStats={data.approverStats}
|
||||||
calculatedStats={calculatedStats}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filtered Request Stats - Approver's Actions */}
|
{/* Filtered Request Stats - Approver's Actions */}
|
||||||
<ApproverPerformanceActionsStats
|
{data.approverStats && (
|
||||||
approverName={approverName}
|
<ApproverPerformanceActionsStats
|
||||||
approverStats={data.approverStats}
|
approverName={approverName}
|
||||||
calculatedStats={calculatedStats}
|
approverStats={data.approverStats}
|
||||||
/>
|
dateRange={filters.dateRange}
|
||||||
|
customStartDate={filters.customStartDate}
|
||||||
|
customEndDate={filters.customEndDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<ApproverPerformanceFilters
|
<ApproverPerformanceFilters
|
||||||
|
|||||||
@ -4,26 +4,30 @@
|
|||||||
|
|
||||||
import { CheckCircle, XCircle, Clock, FileText, Users, Target, Award, AlertCircle, BarChart3, Archive } from 'lucide-react';
|
import { CheckCircle, XCircle, Clock, FileText, Users, Target, Award, AlertCircle, BarChart3, Archive } from 'lucide-react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import type { ApproverPerformance } from '@/services/dashboard.service';
|
import type { ApproverPerformance, DateRange } from '@/services/dashboard.service';
|
||||||
import type { ApproverPerformanceStats } from '../types/approverPerformance.types';
|
import { formatDateRangeText } from '@/pages/Dashboard/utils/dateRangeFormatter';
|
||||||
|
|
||||||
interface ApproverPerformanceActionsStatsProps {
|
interface ApproverPerformanceActionsStatsProps {
|
||||||
approverName: string;
|
approverName: string;
|
||||||
approverStats: ApproverPerformance | null;
|
approverStats: ApproverPerformance;
|
||||||
calculatedStats: ApproverPerformanceStats;
|
dateRange?: DateRange;
|
||||||
|
customStartDate?: Date;
|
||||||
|
customEndDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApproverPerformanceActionsStats({
|
export function ApproverPerformanceActionsStats({
|
||||||
approverName,
|
approverName,
|
||||||
approverStats,
|
approverStats,
|
||||||
calculatedStats
|
dateRange = 'all',
|
||||||
|
customStartDate,
|
||||||
|
customEndDate
|
||||||
}: ApproverPerformanceActionsStatsProps) {
|
}: ApproverPerformanceActionsStatsProps) {
|
||||||
return (
|
return (
|
||||||
<Card data-testid="approver-actions-stats">
|
<Card data-testid="approver-actions-stats">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Approver's Actions (Filtered)</CardTitle>
|
<CardTitle>Approver's Actions</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Statistics based on {approverName}'s actions with current filters applied
|
Statistics for all requests by {approverName} {dateRange && dateRange !== 'all' ? formatDateRangeText(dateRange, customStartDate, customEndDate, '') : '(all time)'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -38,41 +42,45 @@ export function ApproverPerformanceActionsStats({
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
<span className="text-xs text-green-600 font-medium">
|
<span className="text-xs text-green-600 font-medium">
|
||||||
{calculatedStats.completedActions > 0 ? `${calculatedStats.approvalRate}%` : '0%'}
|
{approverStats && (approverStats.approvedCount + approverStats.rejectedCount) > 0
|
||||||
|
? `${Math.round((approverStats.approvedCount / (approverStats.approvedCount + approverStats.rejectedCount)) * 100)}%`
|
||||||
|
: '0%'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-green-700">{calculatedStats.approvedByApprover}</div>
|
<div className="text-2xl font-bold text-green-700">{approverStats?.approvedCount || 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">Approved by Approver</div>
|
<div className="text-xs text-gray-600 mt-1">Approved by Approver</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
|
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<XCircle className="w-5 h-5 text-red-600" />
|
<XCircle className="w-5 h-5 text-red-600" />
|
||||||
<span className="text-xs text-red-600 font-medium">
|
<span className="text-xs text-red-600 font-medium">
|
||||||
{calculatedStats.completedActions > 0 ? `${calculatedStats.rejectionRate}%` : '0%'}
|
{approverStats && (approverStats.approvedCount + approverStats.rejectedCount) > 0
|
||||||
|
? `${Math.round((approverStats.rejectedCount / (approverStats.approvedCount + approverStats.rejectedCount)) * 100)}%`
|
||||||
|
: '0%'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-red-700">{calculatedStats.rejectedByApprover}</div>
|
<div className="text-2xl font-bold text-red-700">{approverStats?.rejectedCount || 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">Rejected by Approver</div>
|
<div className="text-xs text-gray-600 mt-1">Rejected by Approver</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<Clock className="w-5 h-5 text-yellow-600" />
|
<Clock className="w-5 h-5 text-yellow-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-yellow-700">{calculatedStats.pendingByApprover}</div>
|
<div className="text-2xl font-bold text-yellow-700">{approverStats?.pendingCount || 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">Pending Actions</div>
|
<div className="text-xs text-gray-600 mt-1">Pending Actions</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<Archive className="w-5 h-5 text-gray-600" />
|
<Archive className="w-5 h-5 text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-gray-700">{calculatedStats.closedByApprover}</div>
|
<div className="text-2xl font-bold text-gray-700">{approverStats?.closedCount || 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">Closed Requests</div>
|
<div className="text-xs text-gray-600 mt-1">Closed Requests</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<FileText className="w-5 h-5 text-blue-600" />
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-blue-700">{calculatedStats.total}</div>
|
<div className="text-2xl font-bold text-blue-700">{approverStats?.totalApproved || 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">Total Requests</div>
|
<div className="text-xs text-gray-600 mt-1">Total Requests</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -89,24 +97,24 @@ export function ApproverPerformanceActionsStats({
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<Award className="w-5 h-5 text-green-600" />
|
<Award className="w-5 h-5 text-green-600" />
|
||||||
<span className="text-xs text-green-600 font-medium">
|
<span className="text-xs text-green-600 font-medium">
|
||||||
{approverStats?.tatCompliancePercent !== undefined ? `${approverStats.tatCompliancePercent}%` : (calculatedStats.completedActions > 0 ? `${calculatedStats.tatComplianceRate}%` : 'N/A')}
|
{approverStats?.tatCompliancePercent !== undefined ? `${approverStats.tatCompliancePercent}%` : 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-green-700">{calculatedStats.compliant}</div>
|
<div className="text-2xl font-bold text-green-700">{approverStats?.withinTatCount || 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">TAT Compliant</div>
|
<div className="text-xs text-gray-600 mt-1">TAT Compliant</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
|
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-red-700">{calculatedStats.breached}</div>
|
<div className="text-2xl font-bold text-red-700">{approverStats?.breachedCount || 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">TAT Breached</div>
|
<div className="text-xs text-gray-600 mt-1">TAT Breached</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<BarChart3 className="w-5 h-5 text-purple-600" />
|
<BarChart3 className="w-5 h-5 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-purple-700">{calculatedStats.completedActions}</div>
|
<div className="text-2xl font-bold text-purple-700">{approverStats ? (approverStats.approvedCount + approverStats.rejectedCount) : 0}</div>
|
||||||
<div className="text-xs text-gray-600 mt-1">Completed Actions</div>
|
<div className="text-xs text-gray-600 mt-1">Completed Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -136,9 +136,12 @@ export function ApproverPerformanceFilters({
|
|||||||
<SelectValue placeholder="Date Range" />
|
<SelectValue placeholder="Date Range" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
<SelectItem value="today">Today</SelectItem>
|
<SelectItem value="today">Today</SelectItem>
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
<SelectItem value="week">This Week</SelectItem>
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
<SelectItem value="month">This Month</SelectItem>
|
||||||
|
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
||||||
|
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
<SelectItem value="custom">Custom Range</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -219,7 +222,7 @@ export function ApproverPerformanceFilters({
|
|||||||
onTempStartDateChange(customStartDate);
|
onTempStartDateChange(customStartDate);
|
||||||
onTempEndDateChange(customEndDate);
|
onTempEndDateChange(customEndDate);
|
||||||
if (!customStartDate || !customEndDate) {
|
if (!customStartDate || !customEndDate) {
|
||||||
onDateRangeChange('month');
|
onDateRangeChange('all');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
data-testid="cancel-date-button"
|
data-testid="cancel-date-button"
|
||||||
|
|||||||
@ -6,20 +6,16 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { CheckCircle, Clock, Target, Timer } from 'lucide-react';
|
import { CheckCircle, Clock, Target, Timer } from 'lucide-react';
|
||||||
import type { ApproverPerformance } from '@/services/dashboard.service';
|
import type { ApproverPerformance } from '@/services/dashboard.service';
|
||||||
import type { ApproverPerformanceStats } from '../types/approverPerformance.types';
|
|
||||||
|
|
||||||
interface ApproverPerformanceStatsCardsProps {
|
interface ApproverPerformanceStatsCardsProps {
|
||||||
approverStats: ApproverPerformance | null;
|
approverStats: ApproverPerformance;
|
||||||
calculatedStats: ApproverPerformanceStats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApproverPerformanceStatsCards({
|
export function ApproverPerformanceStatsCards({
|
||||||
approverStats,
|
approverStats
|
||||||
calculatedStats
|
|
||||||
}: ApproverPerformanceStatsCardsProps) {
|
}: ApproverPerformanceStatsCardsProps) {
|
||||||
if (!approverStats) return null;
|
|
||||||
|
|
||||||
const tatCompliance = approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate;
|
const tatCompliance = approverStats?.tatCompliancePercent ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-testid="approver-stats-cards">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-testid="approver-stats-cards">
|
||||||
|
|||||||
@ -42,29 +42,40 @@ export function useApproverPerformanceData({
|
|||||||
|
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
// Fetch approver performance stats
|
// Fetch stats for this approver (ONLY on date/priority/SLA changes)
|
||||||
const fetchApproverStats = useCallback(async () => {
|
const fetchApproverStats = useCallback(async () => {
|
||||||
if (!approverId) return;
|
if (!approverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await dashboardService.getApproverPerformance(
|
const dateRangeToSend = dateRange === 'all' ? undefined : dateRange;
|
||||||
dateRange,
|
console.log('[Stats] Fetching with filters:', {
|
||||||
1,
|
dateRange: dateRangeToSend,
|
||||||
100,
|
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate
|
customEndDate,
|
||||||
|
priority: priorityFilter,
|
||||||
|
sla: slaComplianceFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = await dashboardService.getSingleApproverStats(
|
||||||
|
approverId,
|
||||||
|
dateRangeToSend,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
|
priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
|
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const approver = result.performance.find((p: ApproverPerformance) => p.approverId === approverId);
|
console.log('[Stats] Received stats:', stats);
|
||||||
if (approver) {
|
setApproverStats(stats);
|
||||||
setApproverStats(approver);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch approver stats:', error);
|
console.error('[ApproverPerformance] Failed to fetch approver stats:', error);
|
||||||
|
setApproverStats(null);
|
||||||
}
|
}
|
||||||
}, [approverId, dateRange, customStartDate, customEndDate]);
|
}, [approverId, dateRange, customStartDate, customEndDate, priorityFilter, slaComplianceFilter]);
|
||||||
|
|
||||||
// Fetch requests for this approver
|
// Fetch requests for this approver (on ANY filter change)
|
||||||
const fetchRequests = useCallback(async (page: number = 1) => {
|
const fetchRequests = useCallback(async (page: number = 1) => {
|
||||||
if (!approverId) {
|
if (!approverId) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -79,7 +90,7 @@ export function useApproverPerformanceData({
|
|||||||
approverId,
|
approverId,
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
dateRange,
|
dateRange === 'all' ? undefined : dateRange,
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate,
|
customEndDate,
|
||||||
statusFilter !== 'all' ? statusFilter : undefined,
|
statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
@ -91,23 +102,8 @@ export function useApproverPerformanceData({
|
|||||||
setRequests(result.requests);
|
setRequests(result.requests);
|
||||||
setTotalRecords(result.pagination.totalRecords);
|
setTotalRecords(result.pagination.totalRecords);
|
||||||
setTotalPages(result.pagination.totalPages);
|
setTotalPages(result.pagination.totalPages);
|
||||||
setCurrentPage(page);
|
setCurrentPage(result.pagination.currentPage);
|
||||||
|
setAllFilteredRequests(result.requests);
|
||||||
// For stats calculation, fetch ALL data (without pagination)
|
|
||||||
const statsResult = await dashboardService.getRequestsByApprover(
|
|
||||||
approverId,
|
|
||||||
1,
|
|
||||||
10000,
|
|
||||||
dateRange,
|
|
||||||
customStartDate,
|
|
||||||
customEndDate,
|
|
||||||
statusFilter !== 'all' ? statusFilter : undefined,
|
|
||||||
priorityFilter !== 'all' ? priorityFilter : undefined,
|
|
||||||
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined,
|
|
||||||
searchTerm || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
setAllFilteredRequests(statsResult.requests);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch requests:', error);
|
console.error('Failed to fetch requests:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -130,15 +126,29 @@ export function useApproverPerformanceData({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialMount.current) {
|
if (isInitialMount.current) {
|
||||||
isInitialMount.current = false;
|
isInitialMount.current = false;
|
||||||
fetchApproverStats();
|
fetchApproverStats(); // Fetch stats once on mount
|
||||||
fetchRequests(1);
|
fetchRequests(1); // Fetch requests once on mount
|
||||||
}
|
}
|
||||||
}, []); // Only run on mount
|
}, []); // Only run on mount
|
||||||
|
|
||||||
// Refetch when filters change
|
// Refetch stats ONLY when date/priority/SLA changes (NOT status or search)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialMount.current) {
|
if (!isInitialMount.current) {
|
||||||
fetchApproverStats();
|
fetchApproverStats();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
dateRange,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
|
priorityFilter,
|
||||||
|
slaComplianceFilter
|
||||||
|
// NO statusFilter, NO searchTerm - stats don't depend on these
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Refetch requests when ANY filter changes (including status and search)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialMount.current) {
|
||||||
fetchRequests(1);
|
fetchRequests(1);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -150,35 +160,18 @@ export function useApproverPerformanceData({
|
|||||||
priorityFilter,
|
priorityFilter,
|
||||||
slaComplianceFilter,
|
slaComplianceFilter,
|
||||||
searchTerm
|
searchTerm
|
||||||
// fetchApproverStats and fetchRequests excluded to prevent infinite loops
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchApproverStats();
|
fetchApproverStats(); // Refresh stats
|
||||||
fetchRequests(1);
|
fetchRequests(1); // Refresh requests
|
||||||
}, [fetchApproverStats, fetchRequests]);
|
}, [fetchApproverStats, fetchRequests]);
|
||||||
|
|
||||||
const handlePageChange = useCallback((page: number) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
setCurrentPage(page);
|
// Use server-side pagination - fetch from backend
|
||||||
// Use client-side pagination since we have allFilteredRequests
|
fetchRequests(page);
|
||||||
const startIdx = (page - 1) * itemsPerPage;
|
}, [fetchRequests]);
|
||||||
const endIdx = startIdx + itemsPerPage;
|
|
||||||
const paginatedRequests = allFilteredRequests.slice(startIdx, endIdx);
|
|
||||||
setRequests(paginatedRequests);
|
|
||||||
}, [allFilteredRequests, itemsPerPage]);
|
|
||||||
|
|
||||||
// Update paginated data when allFilteredRequests changes
|
|
||||||
useEffect(() => {
|
|
||||||
const startIdx = (currentPage - 1) * itemsPerPage;
|
|
||||||
const endIdx = startIdx + itemsPerPage;
|
|
||||||
const paginatedRequests = allFilteredRequests.slice(startIdx, endIdx);
|
|
||||||
setRequests(paginatedRequests);
|
|
||||||
setTotalPages(Math.ceil(allFilteredRequests.length / itemsPerPage));
|
|
||||||
if (currentPage > Math.ceil(allFilteredRequests.length / itemsPerPage) && allFilteredRequests.length > 0) {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}
|
|
||||||
}, [allFilteredRequests, currentPage, itemsPerPage]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests,
|
requests,
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function useApproverPerformanceFilters() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || 'all');
|
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || 'all');
|
||||||
const [priorityFilter, setPriorityFilter] = useState<string>(searchParams.get('priority') || 'all');
|
const [priorityFilter, setPriorityFilter] = useState<string>(searchParams.get('priority') || 'all');
|
||||||
const [slaComplianceFilter, setSlaComplianceFilter] = useState<string>(searchParams.get('slaCompliance') || 'all');
|
const [slaComplianceFilter, setSlaComplianceFilter] = useState<string>(searchParams.get('slaCompliance') || 'all');
|
||||||
const [dateRange, setDateRange] = useState<DateRange>((searchParams.get('dateRange') as DateRange) || 'month');
|
const [dateRange, setDateRange] = useState<DateRange>((searchParams.get('dateRange') as DateRange) || 'all');
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||||
searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined
|
searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined
|
||||||
);
|
);
|
||||||
@ -29,7 +29,7 @@ export function useApproverPerformanceFilters() {
|
|||||||
setStatusFilter('all');
|
setStatusFilter('all');
|
||||||
setPriorityFilter('all');
|
setPriorityFilter('all');
|
||||||
setSlaComplianceFilter('all');
|
setSlaComplianceFilter('all');
|
||||||
setDateRange('month');
|
setDateRange('all');
|
||||||
setCustomStartDate(undefined);
|
setCustomStartDate(undefined);
|
||||||
setCustomEndDate(undefined);
|
setCustomEndDate(undefined);
|
||||||
setTempCustomStartDate(undefined);
|
setTempCustomStartDate(undefined);
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { buildApprovalLevels } from './approvalLevelBuilders';
|
|||||||
export function buildCreatePayload(
|
export function buildCreatePayload(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
selectedTemplate: RequestTemplate | null,
|
selectedTemplate: RequestTemplate | null,
|
||||||
user: any
|
_user: any
|
||||||
): CreateWorkflowPayload {
|
): CreateWorkflowPayload {
|
||||||
// Filter out spectators who are also approvers (backend will handle validation)
|
// Filter out spectators who are also approvers (backend will handle validation)
|
||||||
const approverEmails = new Set(
|
const approverEmails = new Set(
|
||||||
@ -41,11 +41,13 @@ export function buildCreatePayload(
|
|||||||
tat: a?.tat || '',
|
tat: a?.tat || '',
|
||||||
tatType: a?.tatType || 'hours',
|
tatType: a?.tatType || 'hours',
|
||||||
})),
|
})),
|
||||||
spectators: filteredSpectators.map((s) => ({
|
spectators: filteredSpectators.map((s: any) => ({
|
||||||
|
userId: s?.userId || '',
|
||||||
|
name: s?.name || '',
|
||||||
email: s?.email || '',
|
email: s?.email || '',
|
||||||
})),
|
})),
|
||||||
// Note: participants array is auto-generated by backend
|
ccList: [], // Auto-generated by backend
|
||||||
// No need to send it from frontend
|
participants: [], // Auto-generated by backend from approvers and spectators
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +57,7 @@ export function buildCreatePayload(
|
|||||||
*/
|
*/
|
||||||
export function buildUpdatePayload(
|
export function buildUpdatePayload(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
user: any,
|
_user: any,
|
||||||
documentsToDelete: string[]
|
documentsToDelete: string[]
|
||||||
): UpdateWorkflowPayload {
|
): UpdateWorkflowPayload {
|
||||||
const approvalLevels = buildApprovalLevels(
|
const approvalLevels = buildApprovalLevels(
|
||||||
@ -68,7 +70,7 @@ export function buildUpdatePayload(
|
|||||||
description: formData.description,
|
description: formData.description,
|
||||||
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
|
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
|
||||||
approvalLevels,
|
approvalLevels,
|
||||||
// Note: participants array is auto-generated by backend
|
participants: [], // Auto-generated by backend from approval levels
|
||||||
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,9 +70,12 @@ export function DashboardFiltersBar({
|
|||||||
<SelectValue placeholder="Select period" />
|
<SelectValue placeholder="Select period" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
<SelectItem value="today">Today</SelectItem>
|
<SelectItem value="today">Today</SelectItem>
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
<SelectItem value="week">This Week</SelectItem>
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
<SelectItem value="month">This Month</SelectItem>
|
||||||
|
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
||||||
|
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
<SelectItem value="custom">Custom Range</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -169,9 +172,13 @@ export function DashboardFiltersBar({
|
|||||||
<SelectValue placeholder="Select period" />
|
<SelectValue placeholder="Select period" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
<SelectItem value="today">Today</SelectItem>
|
<SelectItem value="today">Today</SelectItem>
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
<SelectItem value="week">This Week</SelectItem>
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
<SelectItem value="month">This Month</SelectItem>
|
||||||
|
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
||||||
|
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom Range</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard
|
|||||||
import { Pagination } from '@/components/common/Pagination';
|
import { Pagination } from '@/components/common/Pagination';
|
||||||
import { formatBreachTime } from '../../utils/dashboardCalculations';
|
import { formatBreachTime } from '../../utils/dashboardCalculations';
|
||||||
import { KPIClickFilters } from '../../components/types/dashboard.types';
|
import { KPIClickFilters } from '../../components/types/dashboard.types';
|
||||||
|
import { formatDateRangeDescription } from '../../utils/dateRangeFormatter';
|
||||||
|
|
||||||
interface TATBreachReportProps {
|
interface TATBreachReportProps {
|
||||||
breachedRequests: (CriticalRequest | CriticalAlertData)[];
|
breachedRequests: (CriticalRequest | CriticalAlertData)[];
|
||||||
@ -56,7 +57,7 @@ export function TATBreachReport({
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base sm:text-lg lg:text-xl">TAT Breach Report</CardTitle>
|
<CardTitle className="text-base sm:text-lg lg:text-xl">TAT Breach Report</CardTitle>
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
Requests that breached defined turnaround time
|
Requests that breached TAT - {formatDateRangeDescription(dateRange, customStartDate, customEndDate)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { DateRange } from '@/services/dashboard.service';
|
import { DateRange } from '@/services/dashboard.service';
|
||||||
|
|
||||||
export function useDashboardFilters() {
|
export function useDashboardFilters() {
|
||||||
const [dateRange, setDateRange] = useState<DateRange>('month');
|
const [dateRange, setDateRange] = useState<DateRange>('all');
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
|
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
|
||||||
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
|
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
|
||||||
@ -42,7 +42,7 @@ export function useDashboardFilters() {
|
|||||||
setCustomStartDate(undefined);
|
setCustomStartDate(undefined);
|
||||||
setCustomEndDate(undefined);
|
setCustomEndDate(undefined);
|
||||||
setShowCustomDatePicker(false);
|
setShowCustomDatePicker(false);
|
||||||
setDateRange('month');
|
setDateRange('all');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
61
src/pages/Dashboard/utils/dateRangeFormatter.ts
Normal file
61
src/pages/Dashboard/utils/dateRangeFormatter.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import type { DateRange } from '@/services/dashboard.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date range for display in dashboard components
|
||||||
|
* Returns a human-readable string describing the active date filter
|
||||||
|
*/
|
||||||
|
export function formatDateRangeText(
|
||||||
|
dateRange: DateRange,
|
||||||
|
customStartDate?: Date,
|
||||||
|
customEndDate?: Date,
|
||||||
|
prefix: string = 'for'
|
||||||
|
): string {
|
||||||
|
if (dateRange === 'custom' && customStartDate && customEndDate) {
|
||||||
|
return `${prefix} ${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeMap: Record<DateRange, string> = {
|
||||||
|
'all': '(All Time)',
|
||||||
|
'today': `${prefix} Today`,
|
||||||
|
'week': `${prefix} This Week`,
|
||||||
|
'month': `${prefix} This Month`,
|
||||||
|
'quarter': `${prefix} This Quarter`,
|
||||||
|
'year': `${prefix} This Year`,
|
||||||
|
'last30days': `${prefix} Last 30 Days`,
|
||||||
|
'custom': `${prefix} Custom Range`
|
||||||
|
};
|
||||||
|
|
||||||
|
return rangeMap[dateRange] || `${prefix} Selected Period`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date range for card descriptions
|
||||||
|
*/
|
||||||
|
export function formatDateRangeDescription(
|
||||||
|
dateRange: DateRange,
|
||||||
|
customStartDate?: Date,
|
||||||
|
customEndDate?: Date
|
||||||
|
): string {
|
||||||
|
if (dateRange === 'all') {
|
||||||
|
return 'All historical data';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateRange === 'custom' && customStartDate && customEndDate) {
|
||||||
|
return `Data from ${format(customStartDate, 'MMM d, yyyy')} to ${format(customEndDate, 'MMM d, yyyy')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeMap: Record<DateRange, string> = {
|
||||||
|
'all': 'All historical data',
|
||||||
|
'today': "Today's data",
|
||||||
|
'week': 'This week data',
|
||||||
|
'month': 'This month data',
|
||||||
|
'quarter': 'This quarter data',
|
||||||
|
'year': 'This year data',
|
||||||
|
'last30days': 'Last 30 days data',
|
||||||
|
'custom': 'Custom date range'
|
||||||
|
};
|
||||||
|
|
||||||
|
return rangeMap[dateRange] || 'Filtered data';
|
||||||
|
}
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
|
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
|
||||||
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
interface QuickActionsSidebarProps {
|
interface QuickActionsSidebarProps {
|
||||||
request: any;
|
request: any;
|
||||||
@ -42,16 +43,22 @@ export function QuickActionsSidebar({
|
|||||||
summaryId,
|
summaryId,
|
||||||
refreshTrigger,
|
refreshTrigger,
|
||||||
}: QuickActionsSidebarProps) {
|
}: QuickActionsSidebarProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||||
const isClosed = request?.status === 'closed';
|
const isClosed = request?.status === 'closed';
|
||||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||||
|
const pausedByUserId = request?.pauseInfo?.pausedBy?.userId;
|
||||||
|
const currentUserId = (user as any)?.userId || '';
|
||||||
|
|
||||||
// Both approver AND initiator can pause (when not already paused and not closed)
|
// Both approver AND initiator can pause (when not already paused and not closed)
|
||||||
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
|
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
|
||||||
// Both approver AND initiator can resume directly
|
|
||||||
|
// Resume: Can be done by the person who paused OR by both initiator and approver
|
||||||
const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator);
|
const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator);
|
||||||
// Retrigger is no longer needed since initiator can resume directly
|
|
||||||
const canRetrigger = false; // Disabled - kept for backwards compatibility
|
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
||||||
|
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
||||||
|
|
||||||
// Fetch shared recipients when request is closed and summaryId is available
|
// Fetch shared recipients when request is closed and summaryId is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||||
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2, Pause, Play, AlertCircle } from 'lucide-react';
|
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2, Pause, Play, AlertCircle } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
@ -48,18 +49,21 @@ export function OverviewTab({
|
|||||||
onResume,
|
onResume,
|
||||||
onRetrigger,
|
onRetrigger,
|
||||||
currentUserIsApprover = false,
|
currentUserIsApprover = false,
|
||||||
pausedByUserId,
|
pausedByUserId: _pausedByUserId,
|
||||||
currentUserId,
|
currentUserId: _currentUserId,
|
||||||
}: OverviewTabProps) {
|
}: OverviewTabProps) {
|
||||||
void _onPause; // Marked as intentionally unused - available for future use
|
void _onPause; // Marked as intentionally unused - available for future use
|
||||||
void pausedByUserId; // Kept for backwards compatibility
|
const { user } = useAuth();
|
||||||
void currentUserId; // Kept for backwards compatibility
|
|
||||||
const pauseInfo = request?.pauseInfo;
|
const pauseInfo = request?.pauseInfo;
|
||||||
const isPaused = pauseInfo?.isPaused || false;
|
const isPaused = pauseInfo?.isPaused || false;
|
||||||
// Both approver AND initiator can resume directly
|
const pausedByUserId = pauseInfo?.pausedBy?.userId;
|
||||||
const canResume = isPaused && (currentUserIsApprover || isInitiator);
|
const currentUserId = (user as any)?.userId || '';
|
||||||
// Retrigger is no longer needed since initiator can resume directly
|
|
||||||
const canRetrigger = false; // Disabled - kept for backwards compatibility
|
// Resume: Can be done by both initiator and approver
|
||||||
|
const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator);
|
||||||
|
|
||||||
|
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
||||||
|
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
|
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
|
||||||
{/* Request Initiator Card */}
|
{/* Request Initiator Card */}
|
||||||
|
|||||||
@ -319,8 +319,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
||||||
};
|
};
|
||||||
// All Requests (admin/normal user) should always have a date range
|
// All Requests (admin/normal user) should always have a date range
|
||||||
// Default to 'month' if no date range is selected
|
// Default to 'all' if no date range is selected
|
||||||
const statsDateRange = filters.dateRange || 'month';
|
const statsDateRange = filters.dateRange || 'all';
|
||||||
|
|
||||||
fetchBackendStatsRef.current(
|
fetchBackendStatsRef.current(
|
||||||
statsDateRange,
|
statsDateRange,
|
||||||
@ -660,9 +660,12 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
<SelectValue placeholder="Date Range" />
|
<SelectValue placeholder="Date Range" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
<SelectItem value="today">Today</SelectItem>
|
<SelectItem value="today">Today</SelectItem>
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
<SelectItem value="week">This Week</SelectItem>
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
<SelectItem value="month">This Month</SelectItem>
|
||||||
|
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
||||||
|
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
<SelectItem value="custom">Custom Range</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@ -580,9 +580,12 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
<SelectValue placeholder="Date Range" />
|
<SelectValue placeholder="Date Range" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
<SelectItem value="today">Today</SelectItem>
|
<SelectItem value="today">Today</SelectItem>
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
<SelectItem value="week">This Week</SelectItem>
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
<SelectItem value="month">This Month</SelectItem>
|
||||||
|
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
||||||
|
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
<SelectItem value="custom">Custom Range</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function useRequestsFilters() {
|
|||||||
const [approverFilterType, setApproverFilterType] = useState<'current' | 'any'>(
|
const [approverFilterType, setApproverFilterType] = useState<'current' | 'any'>(
|
||||||
searchParams.get('approverType') === 'any' ? 'any' : 'current'
|
searchParams.get('approverType') === 'any' ? 'any' : 'current'
|
||||||
);
|
);
|
||||||
const [dateRange, setDateRange] = useState<DateRange>((searchParams.get('dateRange') as DateRange) || 'month');
|
const [dateRange, setDateRange] = useState<DateRange>((searchParams.get('dateRange') as DateRange) || 'all');
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||||
searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined
|
searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined
|
||||||
);
|
);
|
||||||
@ -101,7 +101,7 @@ export function useRequestsFilters() {
|
|||||||
setInitiatorFilter('all');
|
setInitiatorFilter('all');
|
||||||
setApproverFilter('all');
|
setApproverFilter('all');
|
||||||
setApproverFilterType('current');
|
setApproverFilterType('current');
|
||||||
setDateRange('month');
|
setDateRange('all');
|
||||||
setCustomStartDate(undefined);
|
setCustomStartDate(undefined);
|
||||||
setCustomEndDate(undefined);
|
setCustomEndDate(undefined);
|
||||||
setShowCustomDatePicker(false);
|
setShowCustomDatePicker(false);
|
||||||
@ -138,7 +138,7 @@ export function useRequestsFilters() {
|
|||||||
departmentFilter !== 'all' ||
|
departmentFilter !== 'all' ||
|
||||||
initiatorFilter !== 'all' ||
|
initiatorFilter !== 'all' ||
|
||||||
approverFilter !== 'all' ||
|
approverFilter !== 'all' ||
|
||||||
dateRange !== 'month' ||
|
dateRange !== 'all' ||
|
||||||
customStartDate ||
|
customStartDate ||
|
||||||
customEndDate
|
customEndDate
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,7 +16,9 @@ import { ConfigurationManager } from '@/components/admin/ConfigurationManager';
|
|||||||
import { HolidayManager } from '@/components/admin/HolidayManager';
|
import { HolidayManager } from '@/components/admin/HolidayManager';
|
||||||
import { UserRoleManager } from '@/components/admin/UserRoleManager';
|
import { UserRoleManager } from '@/components/admin/UserRoleManager';
|
||||||
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
|
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
|
||||||
import { useState } from 'react';
|
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getUserSubscriptions } from '@/services/notificationApi';
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -25,6 +27,26 @@ export function Settings() {
|
|||||||
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 [isEnablingNotifications, setIsEnablingNotifications] = useState(false);
|
||||||
|
const [showPreferencesModal, setShowPreferencesModal] = useState(false);
|
||||||
|
const [hasSubscription, setHasSubscription] = useState(false);
|
||||||
|
const [checkingSubscription, setCheckingSubscription] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSubscriptionStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkSubscriptionStatus = async () => {
|
||||||
|
try {
|
||||||
|
setCheckingSubscription(true);
|
||||||
|
const subscriptions = await getUserSubscriptions();
|
||||||
|
setHasSubscription(subscriptions.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check subscription status:', error);
|
||||||
|
setHasSubscription(false);
|
||||||
|
} finally {
|
||||||
|
setCheckingSubscription(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnableNotifications = async () => {
|
const handleEnableNotifications = async () => {
|
||||||
setIsEnablingNotifications(true);
|
setIsEnablingNotifications(true);
|
||||||
@ -93,6 +115,8 @@ export function Settings() {
|
|||||||
setNotificationSuccess(true);
|
setNotificationSuccess(true);
|
||||||
setNotificationMessage('Push notifications have been successfully enabled! You will now receive 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);
|
||||||
|
// Recheck subscription status
|
||||||
|
await checkSubscriptionStatus();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Settings] Error enabling notifications:', error);
|
console.error('[Settings] Error enabling notifications:', error);
|
||||||
setNotificationSuccess(false);
|
setNotificationSuccess(false);
|
||||||
@ -177,7 +201,7 @@ export function Settings() {
|
|||||||
{/* User Settings Tab */}
|
{/* User Settings Tab */}
|
||||||
<TabsContent value="user" className="mt-0 space-y-0">
|
<TabsContent value="user" className="mt-0 space-y-0">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
{/* Notification Settings */}
|
{/* Enable Push Notifications Setup */}
|
||||||
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
|
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -185,20 +209,38 @@ export function Settings() {
|
|||||||
<Bell className="h-5 w-5 text-white" />
|
<Bell className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold text-gray-900">Notifications</CardTitle>
|
<CardTitle className="text-lg font-semibold text-gray-900">Browser Push Setup</CardTitle>
|
||||||
<CardDescription className="text-sm text-gray-600">Manage notification preferences</CardDescription>
|
<CardDescription className="text-sm text-gray-600">Register this browser for push notifications</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{checkingSubscription ? (
|
||||||
|
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-600">Checking registration status...</p>
|
||||||
|
</div>
|
||||||
|
) : hasSubscription ? (
|
||||||
|
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<p className="text-xs text-green-800 font-medium">
|
||||||
|
✓ This browser is already registered for push notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
Click below to register this browser for receiving push notifications.
|
||||||
|
This needs to be done once per browser/device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleEnableNotifications}
|
onClick={handleEnableNotifications}
|
||||||
disabled={isEnablingNotifications}
|
disabled={isEnablingNotifications || hasSubscription}
|
||||||
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
||||||
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
|
{isEnablingNotifications ? 'Registering...' : hasSubscription ? 'Already Registered' : 'Register Browser for Push'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -257,16 +299,18 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold text-gray-900">Preferences</CardTitle>
|
<CardTitle className="text-lg font-semibold text-gray-900">Preferences</CardTitle>
|
||||||
<CardDescription className="text-sm text-gray-600">Application preferences</CardDescription>
|
<CardDescription className="text-sm text-gray-600">Notification and application preferences</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<Button
|
||||||
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl border border-gray-200">
|
onClick={() => setShowPreferencesModal(true)}
|
||||||
<p className="text-sm text-gray-600 text-center">User preferences will be available soon</p>
|
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
|
||||||
</div>
|
>
|
||||||
</div>
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Manage Preferences
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -292,7 +336,7 @@ export function Settings() {
|
|||||||
<>
|
<>
|
||||||
{/* Non-Admin User Settings Only */}
|
{/* Non-Admin User Settings Only */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
{/* Notification Settings */}
|
{/* Enable Push Notifications Setup */}
|
||||||
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
|
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -300,20 +344,38 @@ export function Settings() {
|
|||||||
<Bell className="h-5 w-5 text-white" />
|
<Bell className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold text-gray-900">Notifications</CardTitle>
|
<CardTitle className="text-lg font-semibold text-gray-900">Browser Push Setup</CardTitle>
|
||||||
<CardDescription className="text-sm text-gray-600">Manage notification preferences</CardDescription>
|
<CardDescription className="text-sm text-gray-600">Register this browser for push notifications</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{checkingSubscription ? (
|
||||||
|
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-600">Checking registration status...</p>
|
||||||
|
</div>
|
||||||
|
) : hasSubscription ? (
|
||||||
|
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<p className="text-xs text-green-800 font-medium">
|
||||||
|
✓ This browser is already registered for push notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
Click below to register this browser for receiving push notifications.
|
||||||
|
This needs to be done once per browser/device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleEnableNotifications}
|
onClick={handleEnableNotifications}
|
||||||
disabled={isEnablingNotifications}
|
disabled={isEnablingNotifications || hasSubscription}
|
||||||
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
||||||
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
|
{isEnablingNotifications ? 'Registering...' : hasSubscription ? 'Already Registered' : 'Register Browser for Push'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -372,16 +434,18 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold text-gray-900">Preferences</CardTitle>
|
<CardTitle className="text-lg font-semibold text-gray-900">Preferences</CardTitle>
|
||||||
<CardDescription className="text-sm text-gray-600">Application preferences</CardDescription>
|
<CardDescription className="text-sm text-gray-600">Notification and application preferences</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<Button
|
||||||
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
|
onClick={() => setShowPreferencesModal(true)}
|
||||||
<p className="text-sm text-gray-600 text-center">User preferences will be available soon</p>
|
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
|
||||||
</div>
|
>
|
||||||
</div>
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Manage Preferences
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -395,6 +459,11 @@ export function Settings() {
|
|||||||
success={notificationSuccess}
|
success={notificationSuccess}
|
||||||
message={notificationMessage}
|
message={notificationMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NotificationPreferencesModal
|
||||||
|
open={showPreferencesModal}
|
||||||
|
onClose={() => setShowPreferencesModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,9 +119,14 @@ export interface ApproverPerformance {
|
|||||||
approverId: string;
|
approverId: string;
|
||||||
approverName: string;
|
approverName: string;
|
||||||
totalApproved: number;
|
totalApproved: number;
|
||||||
|
approvedCount: number;
|
||||||
|
rejectedCount: number;
|
||||||
|
closedCount: number;
|
||||||
tatCompliancePercent: number;
|
tatCompliancePercent: number;
|
||||||
avgResponseHours: number;
|
avgResponseHours: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
|
withinTatCount: number;
|
||||||
|
breachedCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpcomingDeadline {
|
export interface UpcomingDeadline {
|
||||||
@ -158,7 +163,7 @@ export interface PriorityDistribution {
|
|||||||
complianceRate: number;
|
complianceRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days' | 'custom';
|
export type DateRange = 'all' | 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days' | 'custom';
|
||||||
|
|
||||||
class DashboardService {
|
class DashboardService {
|
||||||
/**
|
/**
|
||||||
@ -442,8 +447,17 @@ class DashboardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Approver Performance metrics with pagination
|
* Get Approver Performance metrics with pagination
|
||||||
|
* Supports priority and SLA filters for consistent stats behavior
|
||||||
*/
|
*/
|
||||||
async getApproverPerformance(dateRange?: DateRange, page: number = 1, limit: number = 10, startDate?: Date, endDate?: Date): Promise<{
|
async getApproverPerformance(
|
||||||
|
dateRange?: DateRange,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10,
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date,
|
||||||
|
priority?: string,
|
||||||
|
slaCompliance?: string
|
||||||
|
): Promise<{
|
||||||
performance: ApproverPerformance[],
|
performance: ApproverPerformance[],
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: number,
|
currentPage: number,
|
||||||
@ -453,11 +467,24 @@ class DashboardService {
|
|||||||
}
|
}
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const params: any = { dateRange, page, limit };
|
const params: any = {
|
||||||
|
dateRange,
|
||||||
|
page,
|
||||||
|
limit: limit || 10 // Explicitly set limit (default 10 if not provided)
|
||||||
|
};
|
||||||
if (dateRange === 'custom' && startDate && endDate) {
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
params.startDate = startDate.toISOString();
|
params.startDate = startDate.toISOString();
|
||||||
params.endDate = endDate.toISOString();
|
params.endDate = endDate.toISOString();
|
||||||
}
|
}
|
||||||
|
if (priority && priority !== 'all') {
|
||||||
|
params.priority = priority;
|
||||||
|
}
|
||||||
|
if (slaCompliance && slaCompliance !== 'all') {
|
||||||
|
params.slaCompliance = slaCompliance;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Dashboard Service] Fetching approver performance with params:', params);
|
||||||
|
|
||||||
const response = await apiClient.get('/dashboard/stats/approver-performance', { params });
|
const response = await apiClient.get('/dashboard/stats/approver-performance', { params });
|
||||||
return {
|
return {
|
||||||
performance: response.data.data,
|
performance: response.data.data,
|
||||||
@ -595,6 +622,36 @@ class DashboardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single approver stats only (dedicated endpoint for performance)
|
||||||
|
* Only respects date, priority, and SLA filters
|
||||||
|
*/
|
||||||
|
async getSingleApproverStats(
|
||||||
|
approverId: string,
|
||||||
|
dateRange?: DateRange,
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date,
|
||||||
|
priority?: string,
|
||||||
|
slaCompliance?: string
|
||||||
|
): Promise<ApproverPerformance> {
|
||||||
|
try {
|
||||||
|
const params: any = { approverId };
|
||||||
|
if (dateRange) params.dateRange = dateRange;
|
||||||
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
|
params.startDate = startDate.toISOString();
|
||||||
|
params.endDate = endDate.toISOString();
|
||||||
|
}
|
||||||
|
if (priority && priority !== 'all') params.priority = priority;
|
||||||
|
if (slaCompliance && slaCompliance !== 'all') params.slaCompliance = slaCompliance;
|
||||||
|
|
||||||
|
const response = await apiClient.get('/dashboard/stats/single-approver', { params });
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch single approver stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get requests filtered by approver ID for detailed performance analysis
|
* Get requests filtered by approver ID for detailed performance analysis
|
||||||
*/
|
*/
|
||||||
@ -610,7 +667,7 @@ class DashboardService {
|
|||||||
slaCompliance?: string,
|
slaCompliance?: string,
|
||||||
search?: string
|
search?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
requests: any[],
|
requests: any[],
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: number,
|
currentPage: number,
|
||||||
totalPages: number,
|
totalPages: number,
|
||||||
|
|||||||
@ -12,27 +12,33 @@ export interface Notification {
|
|||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
sentVia: string[];
|
|
||||||
readAt?: string;
|
readAt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotificationApi {
|
export interface PushSubscription {
|
||||||
|
subscriptionId: string;
|
||||||
|
endpoint: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationApi = {
|
||||||
/**
|
/**
|
||||||
* Get user's notifications
|
* Get user's notifications with pagination
|
||||||
*/
|
*/
|
||||||
async list(params?: { page?: number; limit?: number; unreadOnly?: boolean }) {
|
async list(params?: { page?: number; limit?: number; unreadOnly?: boolean }) {
|
||||||
const response = await apiClient.get('/notifications', { params });
|
const response = await apiClient.get('/notifications', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread count
|
* Get unread notification count
|
||||||
*/
|
*/
|
||||||
async getUnreadCount() {
|
async getUnreadCount() {
|
||||||
const response = await apiClient.get('/notifications/unread-count');
|
const response = await apiClient.get('/notifications/unread-count');
|
||||||
return response.data.data.unreadCount;
|
return response.data;
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark notification as read
|
* Mark notification as read
|
||||||
@ -40,15 +46,15 @@ class NotificationApi {
|
|||||||
async markAsRead(notificationId: string) {
|
async markAsRead(notificationId: string) {
|
||||||
const response = await apiClient.patch(`/notifications/${notificationId}/read`);
|
const response = await apiClient.patch(`/notifications/${notificationId}/read`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all as read
|
* Mark all notifications as read
|
||||||
*/
|
*/
|
||||||
async markAllAsRead() {
|
async markAllAsRead() {
|
||||||
const response = await apiClient.post('/notifications/mark-all-read');
|
const response = await apiClient.post('/notifications/mark-all-read');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete notification
|
* Delete notification
|
||||||
@ -57,8 +63,16 @@ class NotificationApi {
|
|||||||
const response = await apiClient.delete(`/notifications/${notificationId}`);
|
const response = await apiClient.delete(`/notifications/${notificationId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's push notification subscriptions
|
||||||
|
*/
|
||||||
|
export const getUserSubscriptions = async (): Promise<PushSubscription[]> => {
|
||||||
|
const response = await apiClient.get<{ success: boolean; data: { subscriptions: PushSubscription[]; count: number } }>(
|
||||||
|
'/notifications/subscriptions'
|
||||||
|
);
|
||||||
|
return response.data.data.subscriptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const notificationApi = new NotificationApi();
|
|
||||||
export default notificationApi;
|
export default notificationApi;
|
||||||
|
|
||||||
|
|||||||
37
src/services/userPreferenceApi.ts
Normal file
37
src/services/userPreferenceApi.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import apiClient from './authApi';
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
emailNotificationsEnabled: boolean;
|
||||||
|
pushNotificationsEnabled: boolean;
|
||||||
|
inAppNotificationsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNotificationPreferences {
|
||||||
|
emailNotificationsEnabled?: boolean;
|
||||||
|
pushNotificationsEnabled?: boolean;
|
||||||
|
inAppNotificationsEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's notification preferences
|
||||||
|
*/
|
||||||
|
export const getNotificationPreferences = async (): Promise<NotificationPreferences> => {
|
||||||
|
const response = await apiClient.get<{ success: boolean; data: NotificationPreferences }>(
|
||||||
|
'/user/preferences/notifications'
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current user's notification preferences
|
||||||
|
*/
|
||||||
|
export const updateNotificationPreferences = async (
|
||||||
|
preferences: UpdateNotificationPreferences
|
||||||
|
): Promise<NotificationPreferences> => {
|
||||||
|
const response = await apiClient.put<{ success: boolean; data: NotificationPreferences }>(
|
||||||
|
'/user/preferences/notifications',
|
||||||
|
preferences
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
@ -72,6 +72,11 @@ export const getStatusConfig = (status: string) => {
|
|||||||
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||||
label: 'pending'
|
label: 'pending'
|
||||||
};
|
};
|
||||||
|
case 'paused':
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-400 text-gray-100 border-gray-500',
|
||||||
|
label: 'paused'
|
||||||
|
};
|
||||||
case 'in-review':
|
case 'in-review':
|
||||||
return {
|
return {
|
||||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user