redis server took from internal server and created dashboard and add spctctor and approver validated
This commit is contained in:
parent
7d5b93ae50
commit
a030179d3c
7
package-lock.json
generated
7
package-lock.json
generated
@ -43,6 +43,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"input-otp": "^1.2.4",
|
||||
@ -4060,6 +4061,12 @@
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"input-otp": "^1.2.4",
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { searchUsers, type UserSummary } from '@/services/userApi';
|
||||
import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle, Lightbulb } from 'lucide-react';
|
||||
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||
|
||||
interface ApprovalLevelInfo {
|
||||
levelNumber: number;
|
||||
@ -41,7 +41,21 @@ export function AddApproverModal({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
|
||||
const searchTimer = useRef<any>(null);
|
||||
|
||||
// Validation modal state
|
||||
const [validationModal, setValidationModal] = useState<{
|
||||
open: boolean;
|
||||
type: 'error' | 'not-found';
|
||||
email: string;
|
||||
message: string;
|
||||
}>({
|
||||
open: false,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
// Calculate available levels (after completed levels)
|
||||
const completedLevels = currentLevels.filter(l =>
|
||||
@ -64,36 +78,66 @@ export function AddApproverModal({
|
||||
const emailToAdd = email.trim().toLowerCase();
|
||||
|
||||
if (!emailToAdd) {
|
||||
alert('Please enter an email address');
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: 'Please enter an email address'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(emailToAdd)) {
|
||||
alert('Please enter a valid email address');
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: 'Please enter a valid email address'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate TAT hours
|
||||
if (!tatHours || tatHours <= 0) {
|
||||
alert('Please enter valid TAT hours (minimum 1 hour)');
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: 'Please enter valid TAT hours (minimum 1 hour)'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (tatHours > 720) {
|
||||
alert('TAT hours cannot exceed 720 hours (30 days)');
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: 'TAT hours cannot exceed 720 hours (30 days)'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate level
|
||||
if (!selectedLevel) {
|
||||
alert('Please select an approval level');
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: 'Please select an approval level'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedLevel < minLevel) {
|
||||
alert(`Cannot add approver at level ${selectedLevel}. Minimum allowed level is ${minLevel} (after completed levels)`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: `Cannot add approver at level ${selectedLevel}. Minimum allowed level is ${minLevel} (after completed levels)`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -107,16 +151,76 @@ export function AddApproverModal({
|
||||
const userName = existingParticipant.name || emailToAdd;
|
||||
|
||||
if (participantType === 'INITIATOR') {
|
||||
alert(`${userName} is the request initiator and cannot be added as an approver.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is the request initiator and cannot be added as an approver.`
|
||||
});
|
||||
return;
|
||||
} else if (participantType === 'APPROVER') {
|
||||
alert(`${userName} is already an approver on this request.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is already an approver on this request.`
|
||||
});
|
||||
return;
|
||||
} else if (participantType === 'SPECTATOR') {
|
||||
alert(`${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.`
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
alert(`${userName} is already a participant on this request.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is already a participant on this request.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If user was NOT selected via @ search, validate against Okta
|
||||
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
|
||||
try {
|
||||
const searchOktaResults = await searchUsers(emailToAdd, 1);
|
||||
|
||||
if (searchOktaResults.length === 0) {
|
||||
// User not found in Okta
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'not-found',
|
||||
email: emailToAdd,
|
||||
message: ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User found - ensure they exist in DB
|
||||
const foundUser = searchOktaResults[0];
|
||||
await ensureUserExists({
|
||||
userId: foundUser.userId,
|
||||
email: foundUser.email,
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
lastName: foundUser.lastName,
|
||||
department: foundUser.department
|
||||
});
|
||||
|
||||
console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to validate approver:', error);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: 'Failed to validate user. Please try again.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -127,6 +231,7 @@ export function AddApproverModal({
|
||||
setEmail('');
|
||||
setTatHours(24);
|
||||
setSelectedLevel(null);
|
||||
setSelectedUser(null);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add approver:', error);
|
||||
@ -141,6 +246,7 @@ export function AddApproverModal({
|
||||
setEmail('');
|
||||
setTatHours(24);
|
||||
setSelectedLevel(null);
|
||||
setSelectedUser(null);
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
onClose();
|
||||
@ -170,6 +276,11 @@ export function AddApproverModal({
|
||||
const handleEmailChange = (value: string) => {
|
||||
setEmail(value);
|
||||
|
||||
// Clear selectedUser when manually editing (forces revalidation)
|
||||
if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) {
|
||||
setSelectedUser(null);
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (searchTimer.current) {
|
||||
clearTimeout(searchTimer.current);
|
||||
@ -199,10 +310,32 @@ export function AddApproverModal({
|
||||
};
|
||||
|
||||
// Select user from search results
|
||||
const handleSelectUser = (user: UserSummary) => {
|
||||
setEmail(user.email);
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
const handleSelectUser = async (user: UserSummary) => {
|
||||
// Ensure user exists in DB when selected via @ search
|
||||
try {
|
||||
await ensureUserExists({
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
department: user.department
|
||||
});
|
||||
|
||||
setEmail(user.email);
|
||||
setSelectedUser(user); // Track that user was selected via @ search
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure user exists:', error);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: user.email,
|
||||
message: 'Failed to verify user in database. Please try again.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -419,6 +552,75 @@ export function AddApproverModal({
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Validation Error Modal */}
|
||||
<Dialog open={validationModal.open} onOpenChange={(isOpen) => setValidationModal(prev => ({ ...prev, open: isOpen }))}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{validationModal.type === 'not-found' ? (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
User Not Found
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
Validation Error
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
{validationModal.type === 'not-found' && (
|
||||
<>
|
||||
<p className="text-gray-700">
|
||||
User <strong>{validationModal.email}</strong> was not found in the organization directory.
|
||||
</p>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
|
||||
<p className="text-sm text-red-800 font-semibold">Please verify:</p>
|
||||
<ul className="text-sm text-red-700 space-y-1 list-disc list-inside">
|
||||
<li>Email address is spelled correctly</li>
|
||||
<li>User exists in Okta/SSO system</li>
|
||||
<li>User has an active account</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-sm text-blue-800 flex items-center gap-1">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
<strong>Tip:</strong> Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users from the directory.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{validationModal.type === 'error' && (
|
||||
<>
|
||||
{validationModal.email && (
|
||||
<p className="text-gray-700">
|
||||
Failed to validate <strong>{validationModal.email}</strong>.
|
||||
</p>
|
||||
)}
|
||||
{validationModal.message && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-700">{validationModal.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setValidationModal(prev => ({ ...prev, open: false }))}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Eye, X, AtSign } from 'lucide-react';
|
||||
import { searchUsers, type UserSummary } from '@/services/userApi';
|
||||
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
|
||||
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||
|
||||
interface AddSpectatorModalProps {
|
||||
open: boolean;
|
||||
@ -27,20 +27,44 @@ export function AddSpectatorModal({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
|
||||
const searchTimer = useRef<any>(null);
|
||||
|
||||
// Validation modal state
|
||||
const [validationModal, setValidationModal] = useState<{
|
||||
open: boolean;
|
||||
type: 'error' | 'not-found';
|
||||
email: string;
|
||||
message: string;
|
||||
}>({
|
||||
open: false,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const emailToAdd = email.trim().toLowerCase();
|
||||
|
||||
if (!emailToAdd) {
|
||||
alert('Please enter an email address');
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: 'Please enter an email address'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(emailToAdd)) {
|
||||
alert('Please enter a valid email address');
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: 'Please enter a valid email address'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -54,16 +78,76 @@ export function AddSpectatorModal({
|
||||
const userName = existingParticipant.name || emailToAdd;
|
||||
|
||||
if (participantType === 'INITIATOR') {
|
||||
alert(`${userName} is the request initiator and cannot be added as a spectator.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is the request initiator and cannot be added as a spectator.`
|
||||
});
|
||||
return;
|
||||
} else if (participantType === 'APPROVER') {
|
||||
alert(`${userName} is already an approver on this request and cannot be added as a spectator.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is already an approver on this request and cannot be added as a spectator.`
|
||||
});
|
||||
return;
|
||||
} else if (participantType === 'SPECTATOR') {
|
||||
alert(`${userName} is already a spectator on this request.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is already a spectator on this request.`
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
alert(`${userName} is already a participant on this request.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: `${userName} is already a participant on this request.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If user was NOT selected via @ search, validate against Okta
|
||||
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
|
||||
try {
|
||||
const searchOktaResults = await searchUsers(emailToAdd, 1);
|
||||
|
||||
if (searchOktaResults.length === 0) {
|
||||
// User not found in Okta
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'not-found',
|
||||
email: emailToAdd,
|
||||
message: ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User found - ensure they exist in DB
|
||||
const foundUser = searchOktaResults[0];
|
||||
await ensureUserExists({
|
||||
userId: foundUser.userId,
|
||||
email: foundUser.email,
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
lastName: foundUser.lastName,
|
||||
department: foundUser.department
|
||||
});
|
||||
|
||||
console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to validate spectator:', error);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailToAdd,
|
||||
message: 'Failed to validate user. Please try again.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -72,6 +156,7 @@ export function AddSpectatorModal({
|
||||
setIsSubmitting(true);
|
||||
await onConfirm(emailToAdd);
|
||||
setEmail('');
|
||||
setSelectedUser(null);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to add spectator:', error);
|
||||
@ -84,6 +169,7 @@ export function AddSpectatorModal({
|
||||
const handleClose = () => {
|
||||
if (!isSubmitting) {
|
||||
setEmail('');
|
||||
setSelectedUser(null);
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
onClose();
|
||||
@ -103,6 +189,11 @@ export function AddSpectatorModal({
|
||||
const handleEmailChange = (value: string) => {
|
||||
setEmail(value);
|
||||
|
||||
// Clear selectedUser when manually editing (forces revalidation)
|
||||
if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) {
|
||||
setSelectedUser(null);
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (searchTimer.current) {
|
||||
clearTimeout(searchTimer.current);
|
||||
@ -132,10 +223,32 @@ export function AddSpectatorModal({
|
||||
};
|
||||
|
||||
// Select user from search results
|
||||
const handleSelectUser = (user: UserSummary) => {
|
||||
setEmail(user.email);
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
const handleSelectUser = async (user: UserSummary) => {
|
||||
// Ensure user exists in DB when selected via @ search
|
||||
try {
|
||||
await ensureUserExists({
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
department: user.department
|
||||
});
|
||||
|
||||
setEmail(user.email);
|
||||
setSelectedUser(user); // Track that user was selected via @ search
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure user exists:', error);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: user.email,
|
||||
message: 'Failed to verify user in database. Please try again.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -249,6 +362,75 @@ export function AddSpectatorModal({
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Validation Error Modal */}
|
||||
<Dialog open={validationModal.open} onOpenChange={(isOpen) => setValidationModal(prev => ({ ...prev, open: isOpen }))}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{validationModal.type === 'not-found' ? (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
User Not Found
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
Validation Error
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
{validationModal.type === 'not-found' && (
|
||||
<>
|
||||
<p className="text-gray-700">
|
||||
User <strong>{validationModal.email}</strong> was not found in the organization directory.
|
||||
</p>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
|
||||
<p className="text-sm text-red-800 font-semibold">Please verify:</p>
|
||||
<ul className="text-sm text-red-700 space-y-1 list-disc list-inside">
|
||||
<li>Email address is spelled correctly</li>
|
||||
<li>User exists in Okta/SSO system</li>
|
||||
<li>User has an active account</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-sm text-blue-800 flex items-center gap-1">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
<strong>Tip:</strong> Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users from the directory.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{validationModal.type === 'error' && (
|
||||
<>
|
||||
{validationModal.email && (
|
||||
<p className="text-gray-700">
|
||||
Failed to validate <strong>{validationModal.email}</strong>.
|
||||
</p>
|
||||
)}
|
||||
{validationModal.message && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-700">{validationModal.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setValidationModal(prev => ({ ...prev, open: false }))}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
||||
import {
|
||||
@ -221,6 +222,19 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
const [loadingDraft, setLoadingDraft] = useState(isEditing);
|
||||
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
|
||||
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
|
||||
|
||||
// Validation modal states
|
||||
const [validationModal, setValidationModal] = useState<{
|
||||
open: boolean;
|
||||
type: 'error' | 'self-assign' | 'not-found';
|
||||
email: string;
|
||||
message: string;
|
||||
}>({
|
||||
open: false,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
// Fetch draft data when in edit mode
|
||||
useEffect(() => {
|
||||
@ -392,6 +406,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
formData.approvers.every(approver => {
|
||||
if (!approver || !approver.email) return false;
|
||||
|
||||
// Check if email is valid
|
||||
if (!validateEmail(approver.email)) return false;
|
||||
|
||||
// Check if approver has a userId (selected via @ search)
|
||||
// If no userId, it means they manually typed the email
|
||||
if (!approver.userId) {
|
||||
// Will be validated and ensured when moving to next step
|
||||
return true; // Allow for now, will validate on next step
|
||||
}
|
||||
|
||||
// Check TAT validation based on type
|
||||
const tatType = approver.tatType || 'hours';
|
||||
if (tatType === 'hours') {
|
||||
@ -408,8 +432,122 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < totalSteps && isStepValid()) {
|
||||
const nextStep = async () => {
|
||||
if (!isStepValid()) return;
|
||||
|
||||
// Scroll to top on mobile to ensure buttons are visible
|
||||
if (window.innerWidth < 640) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Special validation when leaving step 3 (Approval Workflow)
|
||||
if (currentStep === 3) {
|
||||
// Validate and ensure all approvers with manually entered emails exist
|
||||
const approversToValidate = formData.approvers.filter(a => a && a.email && !a.userId);
|
||||
|
||||
if (approversToValidate.length > 0) {
|
||||
try {
|
||||
// Show loading state (optional - can be added later)
|
||||
const updatedApprovers = [...formData.approvers];
|
||||
|
||||
for (let i = 0; i < updatedApprovers.length; i++) {
|
||||
const approver = updatedApprovers[i];
|
||||
|
||||
// Skip if already has userId (selected via @ search)
|
||||
if (approver.userId) continue;
|
||||
|
||||
// Skip if no email
|
||||
if (!approver.email) continue;
|
||||
|
||||
// Check if this email is the initiator's email
|
||||
const initiatorEmail = (user as any)?.email?.toLowerCase();
|
||||
if (approver.email.toLowerCase() === initiatorEmail) {
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'self-assign',
|
||||
email: approver.email,
|
||||
message: ''
|
||||
});
|
||||
return; // Stop navigation
|
||||
}
|
||||
|
||||
// Search for the user by email in Okta directory
|
||||
try {
|
||||
const searchResults = await searchUsers(approver.email, 1);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
// User NOT found in Okta directory
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'not-found',
|
||||
email: approver.email,
|
||||
message: ''
|
||||
});
|
||||
return; // Stop navigation - user must fix the email
|
||||
} else {
|
||||
// User found in Okta - ensure they exist in our DB and get userId
|
||||
const foundUser = searchResults[0];
|
||||
|
||||
if (!foundUser) {
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: approver.email,
|
||||
message: 'Could not retrieve user details. Please try again.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure user exists in our database (creates if needed)
|
||||
const dbUser = await ensureUserExists({
|
||||
userId: foundUser.userId,
|
||||
email: foundUser.email,
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
lastName: foundUser.lastName,
|
||||
department: foundUser.department
|
||||
});
|
||||
|
||||
// Update approver with DB userId and full details
|
||||
updatedApprovers[i] = {
|
||||
...approver,
|
||||
userId: dbUser.userId,
|
||||
name: dbUser.displayName || approver.name,
|
||||
department: dbUser.department || approver.department,
|
||||
avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase()
|
||||
};
|
||||
|
||||
console.log(`✅ Validated approver ${i + 1}: ${dbUser.displayName} (${dbUser.email})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to validate approver ${approver.email}:`, error);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: approver.email,
|
||||
message: `Failed to validate user. Please try again or select a different user.`
|
||||
});
|
||||
return; // Stop navigation
|
||||
}
|
||||
}
|
||||
|
||||
// Update form data with validated approvers
|
||||
updateFormData('approvers', updatedApprovers);
|
||||
} catch (error) {
|
||||
console.error('Failed to validate approvers:', error);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: '',
|
||||
message: 'Failed to validate approvers. Please try again.'
|
||||
});
|
||||
return; // Stop navigation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed to next step
|
||||
if (currentStep < totalSteps) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
@ -417,6 +555,10 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
const prevStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
// Scroll to top on mobile to ensure content is visible
|
||||
if (window.innerWidth < 640) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -450,7 +592,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
|
||||
// Check if user is already in the target list
|
||||
if (currentList.find((u: any) => u.id === user.id || (u.email || '').toLowerCase() === userEmail)) {
|
||||
alert(`${user.name || user.email} is already added as ${type.slice(0, -1)}.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: user.email,
|
||||
message: `${user.name || user.email} is already added as ${type.slice(0, -1)}.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -461,7 +608,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
a.id === user.id || (a.email || '').toLowerCase() === userEmail
|
||||
);
|
||||
if (isApprover) {
|
||||
alert(`${user.name || user.email} is already an approver and cannot be added as a spectator.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: user.email,
|
||||
message: `${user.name || user.email} is already an approver and cannot be added as a spectator.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (type === 'approvers') {
|
||||
@ -470,7 +622,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
s.id === user.id || (s.email || '').toLowerCase() === userEmail
|
||||
);
|
||||
if (isSpectator) {
|
||||
alert(`${user.name || user.email} is already a spectator and cannot be added as an approver.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: user.email,
|
||||
message: `${user.name || user.email} is already a spectator and cannot be added as an approver.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -524,10 +681,91 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
return newUser;
|
||||
};
|
||||
|
||||
const inviteAndAddUser = (type: 'approvers' | 'spectators' | 'ccList') => {
|
||||
const user = addUserByEmail();
|
||||
if (user) {
|
||||
addUser(user, type);
|
||||
const inviteAndAddUser = async (type: 'approvers' | 'spectators' | 'ccList') => {
|
||||
// For spectators, validate against Okta before adding
|
||||
if (type === 'spectators' && emailInput) {
|
||||
// Check if this email is the initiator's email
|
||||
const initiatorEmail = (user as any)?.email?.toLowerCase();
|
||||
if (emailInput.toLowerCase() === initiatorEmail) {
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'self-assign',
|
||||
email: emailInput,
|
||||
message: 'You cannot add yourself as a spectator.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for user in Okta directory
|
||||
try {
|
||||
const searchResults = await searchUsers(emailInput, 1);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
// User NOT found in Okta directory
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'not-found',
|
||||
email: emailInput,
|
||||
message: ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User found in Okta - ensure they exist in DB
|
||||
const foundUser = searchResults[0];
|
||||
|
||||
if (!foundUser) {
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailInput,
|
||||
message: 'Could not retrieve user details. Please try again.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dbUser = await ensureUserExists({
|
||||
userId: foundUser.userId,
|
||||
email: foundUser.email,
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
lastName: foundUser.lastName,
|
||||
department: foundUser.department
|
||||
});
|
||||
|
||||
// Create spectator object with verified data
|
||||
const spectator = {
|
||||
id: dbUser.userId,
|
||||
userId: dbUser.userId,
|
||||
name: dbUser.displayName || dbUser.email.split('@')[0],
|
||||
email: dbUser.email,
|
||||
avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase(),
|
||||
role: 'Spectator',
|
||||
department: dbUser.department || '',
|
||||
level: 1,
|
||||
canClose: false
|
||||
};
|
||||
|
||||
// Add spectator
|
||||
addUser(spectator, 'spectators');
|
||||
setEmailInput('');
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to validate spectator:', error);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: emailInput,
|
||||
message: 'Failed to validate user. Please try again.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For other types (deprecated flow)
|
||||
const userObj = addUserByEmail();
|
||||
if (userObj) {
|
||||
addUser(userObj, type);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1402,9 +1640,23 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
|
||||
Email Address *
|
||||
</Label>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
|
||||
Email Address *
|
||||
</Label>
|
||||
{formData.approvers[index]?.email && formData.approvers[index]?.userId && (
|
||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
{formData.approvers[index]?.email && !formData.approvers[index]?.userId && (
|
||||
<Badge variant="outline" className="text-xs bg-amber-50 text-amber-700 border-amber-300">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
Needs Validation
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`approver-${level}`}
|
||||
@ -1414,10 +1666,20 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const newApprovers = [...formData.approvers];
|
||||
|
||||
// If email changed, clear userId to force revalidation
|
||||
const previousEmail = newApprovers[index]?.email;
|
||||
const emailChanged = previousEmail !== value;
|
||||
|
||||
newApprovers[index] = {
|
||||
...newApprovers[index],
|
||||
email: value,
|
||||
level: level
|
||||
level: level,
|
||||
// Clear userId if email was changed (requires revalidation)
|
||||
userId: emailChanged ? undefined : newApprovers[index]?.userId,
|
||||
name: emailChanged ? undefined : newApprovers[index]?.name,
|
||||
department: emailChanged ? undefined : newApprovers[index]?.department,
|
||||
avatar: emailChanged ? undefined : newApprovers[index]?.avatar
|
||||
};
|
||||
updateFormData('approvers', newApprovers);
|
||||
|
||||
@ -1462,7 +1724,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
const spectatorIds = (formData.spectators || []).map((s: any) => s?.id).filter(Boolean);
|
||||
const spectatorEmails = (formData.spectators || []).map((s: any) => s?.email?.toLowerCase?.()).filter(Boolean);
|
||||
if (spectatorIds.includes(u.userId) || spectatorEmails.includes((u.email || '').toLowerCase())) {
|
||||
alert(`${u.displayName || u.email} is already a spectator and cannot be added as an approver.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: u.email,
|
||||
message: `${u.displayName || u.email} is already a spectator and cannot be added as an approver.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1472,7 +1739,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
.map((a: any) => a?.email?.toLowerCase?.())
|
||||
.filter(Boolean);
|
||||
if (approverEmails.includes((u.email || '').toLowerCase())) {
|
||||
alert(`${u.displayName || u.email} is already an approver at another level.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: u.email,
|
||||
message: `${u.displayName || u.email} is already an approver at another level.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1515,10 +1787,27 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||
<span className="font-medium">@</span>
|
||||
Use @ sign to tag a user
|
||||
</p>
|
||||
{formData.approvers[index]?.userId ? (
|
||||
<p className="text-xs text-green-600 mt-1 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
User verified in organization directory
|
||||
</p>
|
||||
) : formData.approvers[index]?.email ? (
|
||||
<div className="mt-1 p-2 bg-amber-50 border border-amber-200 rounded">
|
||||
<p className="text-xs text-amber-700 flex items-center gap-1 font-medium">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
This email will be validated against Okta directory
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
If user doesn't exist in Okta, you won't be able to proceed. Use @ search for guaranteed results.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||
<span className="font-medium">@</span>
|
||||
Use @ sign to search and select a user (recommended)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peer Approver Section */}
|
||||
@ -1816,9 +2105,11 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
}
|
||||
}, 300);
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
onKeyPress={async (e) => {
|
||||
// Allow Enter key to add spectator (will validate)
|
||||
if (e.key === 'Enter' && validateEmail(emailInput)) {
|
||||
inviteAndAddUser('spectators');
|
||||
e.preventDefault();
|
||||
await inviteAndAddUser('spectators');
|
||||
}
|
||||
}}
|
||||
className="text-sm w-full"
|
||||
@ -1838,7 +2129,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
|
||||
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
|
||||
if (approverIds.includes(u.userId) || approverEmails.includes((u.email || '').toLowerCase())) {
|
||||
alert(`${u.displayName || u.email} is already an approver and cannot be added as a spectator.`);
|
||||
setValidationModal({
|
||||
open: true,
|
||||
type: 'error',
|
||||
email: u.email,
|
||||
message: `${u.displayName || u.email} is already an approver and cannot be added as a spectator.`
|
||||
});
|
||||
setEmailInput('');
|
||||
setSpectatorSearchResults([]);
|
||||
return;
|
||||
@ -1889,12 +2185,20 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => inviteAndAddUser('spectators')}
|
||||
onClick={async () => {
|
||||
await inviteAndAddUser('spectators');
|
||||
}}
|
||||
disabled={!validateEmail(emailInput)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded p-2 flex items-center gap-1">
|
||||
<Info className="w-3 h-3 flex-shrink-0" />
|
||||
<span>
|
||||
Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users, or type email directly (will be validated against organization directory)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
@ -2501,23 +2805,23 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gradient-to-br from-gray-50 to-white">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-between max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
{/* Header - Compact on Mobile */}
|
||||
<div className="bg-white border-b border-gray-200 px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-between max-w-6xl mx-auto gap-2 sm:gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 h-8 w-8 sm:h-10 sm:w-10">
|
||||
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEditing ? 'Edit Draft Request' : 'Create New Request'}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base sm:text-xl md:text-2xl font-bold text-gray-900 truncate">
|
||||
{isEditing ? 'Edit Draft' : 'New Request'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-xs sm:text-sm text-gray-600 hidden sm:block">
|
||||
Step {currentStep} of {totalSteps}: {STEP_NAMES[currentStep - 1]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">{Math.round((currentStep / totalSteps) * 100)}% Complete</p>
|
||||
<p className="text-xs text-gray-600">{totalSteps - currentStep} steps remaining</p>
|
||||
@ -2526,96 +2830,134 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-3 flex-shrink-0">
|
||||
{/* Progress Bar - Mobile Optimized */}
|
||||
<div className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{STEP_NAMES.map((_, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index + 1 < currentStep
|
||||
? 'bg-green-600 text-white'
|
||||
: index + 1 === currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1 < currentStep ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
index + 1
|
||||
{/* Mobile: Current step indicator only */}
|
||||
<div className="block sm:hidden">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold">
|
||||
{currentStep}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-900">{STEP_NAMES[currentStep - 1]}</p>
|
||||
<p className="text-xs text-gray-600">Step {currentStep} of {totalSteps}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-green-600">{Math.round((currentStep / totalSteps) * 100)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-green-600 h-full transition-all duration-300"
|
||||
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Full step indicator */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{STEP_NAMES.map((_, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index + 1 < currentStep
|
||||
? 'bg-green-600 text-white'
|
||||
: index + 1 === currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1 < currentStep ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
{index < STEP_NAMES.length - 1 && (
|
||||
<div className={`w-8 md:w-12 lg:w-16 h-1 mx-1 md:mx-2 ${
|
||||
index + 1 < currentStep ? 'bg-green-600' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
{index < STEP_NAMES.length - 1 && (
|
||||
<div className={`w-12 lg:w-16 h-1 mx-2 ${
|
||||
index + 1 < currentStep ? 'bg-green-600' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden lg:flex justify-between text-xs text-gray-600 mt-2">
|
||||
{STEP_NAMES.map((step, index) => (
|
||||
<span key={index} className={`${
|
||||
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
|
||||
}`}>
|
||||
{step}
|
||||
</span>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden lg:flex justify-between text-xs text-gray-600 mt-2">
|
||||
{STEP_NAMES.map((step, index) => (
|
||||
<span key={index} className={`${
|
||||
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
|
||||
}`}>
|
||||
{step}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Content - with extra bottom padding for mobile keyboards */}
|
||||
<div className="flex-1 overflow-y-auto pb-24 sm:pb-4">
|
||||
<div className="max-w-6xl mx-auto p-3 sm:p-6 pb-6 sm:pb-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{renderStepContent()}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-white border-t border-gray-200 px-6 py-4 flex-shrink-0">
|
||||
<div className="flex justify-between items-center max-w-6xl mx-auto">
|
||||
{/* Footer - Fixed on mobile for better keyboard handling */}
|
||||
<div className="fixed sm:relative bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0 shadow-lg sm:shadow-none z-50">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-2 sm:gap-4 max-w-6xl mx-auto">
|
||||
{/* Previous Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 1}
|
||||
size="lg"
|
||||
size="sm"
|
||||
className="sm:size-lg order-2 sm:order-1"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Previous
|
||||
<ArrowLeft className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||
<span className="text-xs sm:text-sm">Previous</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={handleSaveDraft} size="lg" disabled={loadingDraft}>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 sm:gap-3 order-1 sm:order-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
size="sm"
|
||||
className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm"
|
||||
disabled={loadingDraft}
|
||||
>
|
||||
{isEditing ? 'Update Draft' : 'Save Draft'}
|
||||
</Button>
|
||||
{currentStep === totalSteps ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isStepValid() || loadingDraft}
|
||||
size="lg"
|
||||
className="bg-green-600 hover:bg-green-700 px-8"
|
||||
size="sm"
|
||||
className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
Submit Request
|
||||
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||
Submit
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
console.log('Next button clicked!');
|
||||
console.log('Current step:', currentStep);
|
||||
console.log('Is step valid:', isStepValid());
|
||||
console.log('Form data:', formData);
|
||||
nextStep();
|
||||
await nextStep();
|
||||
}}
|
||||
disabled={!isStepValid()}
|
||||
size="lg"
|
||||
className="px-8"
|
||||
size="sm"
|
||||
className="sm:size-lg flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
|
||||
>
|
||||
Next Step
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
<span className="hidden sm:inline">Next Step</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
<ArrowRight className="h-3 w-3 sm:h-4 sm:w-4 ml-1 sm:ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -2628,6 +2970,96 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
onClose={() => setShowTemplateModal(false)}
|
||||
onSelectTemplate={handleTemplateSelection}
|
||||
/>
|
||||
|
||||
{/* Validation Error Modal */}
|
||||
<Dialog open={validationModal.open} onOpenChange={(open) => setValidationModal(prev => ({ ...prev, open }))}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{validationModal.type === 'self-assign' && (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-amber-600" />
|
||||
Cannot Add Yourself
|
||||
</>
|
||||
)}
|
||||
{validationModal.type === 'not-found' && (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
User Not Found
|
||||
</>
|
||||
)}
|
||||
{validationModal.type === 'error' && (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
Validation Error
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
{validationModal.type === 'self-assign' && (
|
||||
<>
|
||||
<p className="text-gray-700">
|
||||
You cannot add yourself (<strong>{validationModal.email}</strong>) as an approver.
|
||||
</p>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Why?</strong> The initiator creates the request and cannot approve their own request. Please select a different user.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{validationModal.type === 'not-found' && (
|
||||
<>
|
||||
<p className="text-gray-700">
|
||||
User <strong>{validationModal.email}</strong> was not found in the organization directory.
|
||||
</p>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
|
||||
<p className="text-sm text-red-800 font-semibold">Please verify:</p>
|
||||
<ul className="text-sm text-red-700 space-y-1 list-disc list-inside">
|
||||
<li>Email address is spelled correctly</li>
|
||||
<li>User exists in Okta/SSO system</li>
|
||||
<li>User has an active account</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-sm text-blue-800 flex items-center gap-1">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
<strong>Tip:</strong> Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search and select users from the directory for guaranteed results.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{validationModal.type === 'error' && (
|
||||
<>
|
||||
<p className="text-gray-700">
|
||||
{validationModal.email && (
|
||||
<>Failed to validate <strong>{validationModal.email}</strong>.</>
|
||||
)}
|
||||
{!validationModal.email && <>An error occurred during validation.</>}
|
||||
</p>
|
||||
{validationModal.message && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-700">{validationModal.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setValidationModal(prev => ({ ...prev, open: false }))}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{validationModal.type === 'not-found' ? 'Fix Email' : 'OK'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
305
src/services/dashboard.service.ts
Normal file
305
src/services/dashboard.service.ts
Normal file
@ -0,0 +1,305 @@
|
||||
import apiClient from './authApi';
|
||||
|
||||
export interface RequestStats {
|
||||
totalRequests: number;
|
||||
openRequests: number;
|
||||
approvedRequests: number;
|
||||
rejectedRequests: number;
|
||||
draftRequests: number;
|
||||
changeFromPrevious: {
|
||||
total: string;
|
||||
open: string;
|
||||
approved: string;
|
||||
rejected: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TATEfficiency {
|
||||
avgTATCompliance: number;
|
||||
avgCycleTimeHours: number;
|
||||
avgCycleTimeDays: number;
|
||||
delayedWorkflows: number;
|
||||
totalCompleted: number;
|
||||
compliantWorkflows: number;
|
||||
changeFromPrevious: {
|
||||
compliance: string;
|
||||
cycleTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApproverLoad {
|
||||
pendingActions: number;
|
||||
completedToday: number;
|
||||
completedThisWeek: number;
|
||||
changeFromPrevious: {
|
||||
pending: string;
|
||||
completed: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EngagementStats {
|
||||
workNotesAdded: number;
|
||||
attachmentsUploaded: number;
|
||||
changeFromPrevious: {
|
||||
workNotes: string;
|
||||
attachments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIInsights {
|
||||
avgConclusionRemarkLength: number;
|
||||
aiSummaryAdoptionPercent: number;
|
||||
totalWithConclusion: number;
|
||||
aiGeneratedCount: number;
|
||||
manualCount: number;
|
||||
changeFromPrevious: {
|
||||
adoption: string;
|
||||
length: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardKPIs {
|
||||
requestVolume: RequestStats;
|
||||
tatEfficiency: TATEfficiency;
|
||||
approverLoad: ApproverLoad;
|
||||
engagement: EngagementStats;
|
||||
aiInsights: AIInsights;
|
||||
dateRange: {
|
||||
start: string;
|
||||
end: string;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecentActivity {
|
||||
activityId: string;
|
||||
requestId: string;
|
||||
requestNumber: string;
|
||||
requestTitle: string;
|
||||
type: string;
|
||||
action: string;
|
||||
details?: any;
|
||||
userId: string;
|
||||
userName: string;
|
||||
timestamp: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface CriticalRequest {
|
||||
requestId: string;
|
||||
requestNumber: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
currentLevel: number;
|
||||
totalLevels: number;
|
||||
submissionDate: string;
|
||||
totalTATHours: number;
|
||||
breachCount: number;
|
||||
isCritical: boolean;
|
||||
}
|
||||
|
||||
export interface UpcomingDeadline {
|
||||
levelId: string;
|
||||
requestId: string;
|
||||
requestNumber: string;
|
||||
requestTitle: string;
|
||||
levelNumber: number;
|
||||
approverName: string;
|
||||
approverEmail: string;
|
||||
tatHours: number;
|
||||
elapsedHours: number;
|
||||
remainingHours: number;
|
||||
tatPercentageUsed: number;
|
||||
levelStartTime: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface DepartmentStats {
|
||||
department: string;
|
||||
totalRequests: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
inProgress: number;
|
||||
approvalRate: number;
|
||||
}
|
||||
|
||||
export interface PriorityDistribution {
|
||||
priority: string;
|
||||
totalCount: number;
|
||||
avgCycleTimeHours: number;
|
||||
approvedCount: number;
|
||||
breachedCount: number;
|
||||
complianceRate: number;
|
||||
}
|
||||
|
||||
export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days';
|
||||
|
||||
class DashboardService {
|
||||
/**
|
||||
* Get all KPI metrics
|
||||
*/
|
||||
async getKPIs(dateRange?: DateRange): Promise<DashboardKPIs> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/kpis', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch KPIs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request statistics
|
||||
*/
|
||||
async getRequestStats(dateRange?: DateRange): Promise<RequestStats> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/requests', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch request stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TAT efficiency metrics
|
||||
*/
|
||||
async getTATEfficiency(dateRange?: DateRange): Promise<TATEfficiency> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/tat-efficiency', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch TAT efficiency:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get approver load
|
||||
*/
|
||||
async getApproverLoad(dateRange?: DateRange): Promise<ApproverLoad> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/approver-load', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch approver load:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement statistics
|
||||
*/
|
||||
async getEngagementStats(dateRange?: DateRange): Promise<EngagementStats> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/engagement', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch engagement stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI insights
|
||||
*/
|
||||
async getAIInsights(dateRange?: DateRange): Promise<AIInsights> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/ai-insights', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI insights:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent activity feed
|
||||
*/
|
||||
async getRecentActivity(limit: number = 10): Promise<RecentActivity[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/activity/recent', {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent activity:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical requests
|
||||
*/
|
||||
async getCriticalRequests(): Promise<CriticalRequest[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/requests/critical');
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch critical requests:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming deadlines
|
||||
*/
|
||||
async getUpcomingDeadlines(limit: number = 5): Promise<UpcomingDeadline[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/deadlines/upcoming', {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch upcoming deadlines:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get department-wise statistics
|
||||
*/
|
||||
async getDepartmentStats(dateRange?: DateRange): Promise<DepartmentStats[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/by-department', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch department stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority distribution
|
||||
*/
|
||||
async getPriorityDistribution(dateRange?: DateRange): Promise<PriorityDistribution[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/priority-distribution', {
|
||||
params: { dateRange }
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch priority distribution:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardService = new DashboardService();
|
||||
export default dashboardService;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user