Compare commits
4 Commits
main
...
dev_branch
| Author | SHA1 | Date | |
|---|---|---|---|
| 63738c529b | |||
| a030179d3c | |||
| 7d5b93ae50 | |||
| 3c9d7cb620 |
7
package-lock.json
generated
7
package-lock.json
generated
@ -43,6 +43,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
@ -4060,6 +4061,12 @@
|
|||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@ -48,6 +48,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle, Lightbulb } from 'lucide-react';
|
||||||
import { searchUsers, type UserSummary } from '@/services/userApi';
|
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||||
|
|
||||||
interface ApprovalLevelInfo {
|
interface ApprovalLevelInfo {
|
||||||
levelNumber: number;
|
levelNumber: number;
|
||||||
@ -41,7 +41,21 @@ export function AddApproverModal({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
|
||||||
const searchTimer = useRef<any>(null);
|
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)
|
// Calculate available levels (after completed levels)
|
||||||
const completedLevels = currentLevels.filter(l =>
|
const completedLevels = currentLevels.filter(l =>
|
||||||
@ -64,36 +78,66 @@ export function AddApproverModal({
|
|||||||
const emailToAdd = email.trim().toLowerCase();
|
const emailToAdd = email.trim().toLowerCase();
|
||||||
|
|
||||||
if (!emailToAdd) {
|
if (!emailToAdd) {
|
||||||
alert('Please enter an email address');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'Please enter an email address'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic email validation
|
// Basic email validation
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(emailToAdd)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate TAT hours
|
// Validate TAT hours
|
||||||
if (!tatHours || tatHours <= 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tatHours > 720) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate level
|
// Validate level
|
||||||
if (!selectedLevel) {
|
if (!selectedLevel) {
|
||||||
alert('Please select an approval level');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'Please select an approval level'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLevel < minLevel) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,16 +151,76 @@ export function AddApproverModal({
|
|||||||
const userName = existingParticipant.name || emailToAdd;
|
const userName = existingParticipant.name || emailToAdd;
|
||||||
|
|
||||||
if (participantType === 'INITIATOR') {
|
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;
|
return;
|
||||||
} else if (participantType === 'APPROVER') {
|
} 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;
|
return;
|
||||||
} else if (participantType === 'SPECTATOR') {
|
} 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;
|
return;
|
||||||
} else {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,6 +231,7 @@ export function AddApproverModal({
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setTatHours(24);
|
setTatHours(24);
|
||||||
setSelectedLevel(null);
|
setSelectedLevel(null);
|
||||||
|
setSelectedUser(null);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add approver:', error);
|
console.error('Failed to add approver:', error);
|
||||||
@ -141,6 +246,7 @@ export function AddApproverModal({
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setTatHours(24);
|
setTatHours(24);
|
||||||
setSelectedLevel(null);
|
setSelectedLevel(null);
|
||||||
|
setSelectedUser(null);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
onClose();
|
onClose();
|
||||||
@ -170,6 +276,11 @@ export function AddApproverModal({
|
|||||||
const handleEmailChange = (value: string) => {
|
const handleEmailChange = (value: string) => {
|
||||||
setEmail(value);
|
setEmail(value);
|
||||||
|
|
||||||
|
// Clear selectedUser when manually editing (forces revalidation)
|
||||||
|
if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) {
|
||||||
|
setSelectedUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (searchTimer.current) {
|
if (searchTimer.current) {
|
||||||
clearTimeout(searchTimer.current);
|
clearTimeout(searchTimer.current);
|
||||||
@ -199,10 +310,32 @@ export function AddApproverModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Select user from search results
|
// Select user from search results
|
||||||
const handleSelectUser = (user: UserSummary) => {
|
const handleSelectUser = async (user: UserSummary) => {
|
||||||
setEmail(user.email);
|
// Ensure user exists in DB when selected via @ search
|
||||||
setSearchResults([]);
|
try {
|
||||||
setIsSearching(false);
|
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 (
|
return (
|
||||||
@ -419,6 +552,75 @@ export function AddApproverModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Eye, X, AtSign } from 'lucide-react';
|
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
|
||||||
import { searchUsers, type UserSummary } from '@/services/userApi';
|
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||||
|
|
||||||
interface AddSpectatorModalProps {
|
interface AddSpectatorModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -27,20 +27,44 @@ export function AddSpectatorModal({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
|
||||||
const searchTimer = useRef<any>(null);
|
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 handleConfirm = async () => {
|
||||||
const emailToAdd = email.trim().toLowerCase();
|
const emailToAdd = email.trim().toLowerCase();
|
||||||
|
|
||||||
if (!emailToAdd) {
|
if (!emailToAdd) {
|
||||||
alert('Please enter an email address');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'Please enter an email address'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic email validation
|
// Basic email validation
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(emailToAdd)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,16 +78,76 @@ export function AddSpectatorModal({
|
|||||||
const userName = existingParticipant.name || emailToAdd;
|
const userName = existingParticipant.name || emailToAdd;
|
||||||
|
|
||||||
if (participantType === 'INITIATOR') {
|
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;
|
return;
|
||||||
} else if (participantType === 'APPROVER') {
|
} 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;
|
return;
|
||||||
} else if (participantType === 'SPECTATOR') {
|
} 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;
|
return;
|
||||||
} else {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,6 +156,7 @@ export function AddSpectatorModal({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await onConfirm(emailToAdd);
|
await onConfirm(emailToAdd);
|
||||||
setEmail('');
|
setEmail('');
|
||||||
|
setSelectedUser(null);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add spectator:', error);
|
console.error('Failed to add spectator:', error);
|
||||||
@ -84,6 +169,7 @@ export function AddSpectatorModal({
|
|||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (!isSubmitting) {
|
if (!isSubmitting) {
|
||||||
setEmail('');
|
setEmail('');
|
||||||
|
setSelectedUser(null);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
onClose();
|
onClose();
|
||||||
@ -103,6 +189,11 @@ export function AddSpectatorModal({
|
|||||||
const handleEmailChange = (value: string) => {
|
const handleEmailChange = (value: string) => {
|
||||||
setEmail(value);
|
setEmail(value);
|
||||||
|
|
||||||
|
// Clear selectedUser when manually editing (forces revalidation)
|
||||||
|
if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) {
|
||||||
|
setSelectedUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (searchTimer.current) {
|
if (searchTimer.current) {
|
||||||
clearTimeout(searchTimer.current);
|
clearTimeout(searchTimer.current);
|
||||||
@ -132,10 +223,32 @@ export function AddSpectatorModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Select user from search results
|
// Select user from search results
|
||||||
const handleSelectUser = (user: UserSummary) => {
|
const handleSelectUser = async (user: UserSummary) => {
|
||||||
setEmail(user.email);
|
// Ensure user exists in DB when selected via @ search
|
||||||
setSearchResults([]);
|
try {
|
||||||
setIsSearching(false);
|
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 (
|
return (
|
||||||
@ -249,6 +362,75 @@ export function AddSpectatorModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,13 @@ import { formatWorkingHours, getTimeUntilNextWorking } from '@/utils/slaTracker'
|
|||||||
interface SLATrackerProps {
|
interface SLATrackerProps {
|
||||||
startDate: string | Date;
|
startDate: string | Date;
|
||||||
deadline: string | Date;
|
deadline: string | Date;
|
||||||
|
priority?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
showDetails?: boolean;
|
showDetails?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SLATracker({ startDate, deadline, className = '', showDetails = true }: SLATrackerProps) {
|
export function SLATracker({ startDate, deadline, priority, className = '', showDetails = true }: SLATrackerProps) {
|
||||||
const slaStatus = useSLATracking(startDate, deadline);
|
const slaStatus = useSLATracking(startDate, deadline, priority);
|
||||||
|
|
||||||
if (!slaStatus) {
|
if (!slaStatus) {
|
||||||
return null;
|
return null;
|
||||||
@ -93,7 +94,7 @@ export function SLATracker({ startDate, deadline, className = '', showDetails =
|
|||||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded-md border border-gray-200">
|
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded-md border border-gray-200">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||||
<span className="text-xs text-gray-700">
|
<span className="text-xs text-gray-700">
|
||||||
{getTimeUntilNextWorking()}
|
{getTimeUntilNextWorking(priority)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -7,12 +7,14 @@ import { getSLAStatus, SLAStatus } from '@/utils/slaTracker';
|
|||||||
*
|
*
|
||||||
* @param startDate - When the SLA tracking started
|
* @param startDate - When the SLA tracking started
|
||||||
* @param deadline - When the SLA should complete
|
* @param deadline - When the SLA should complete
|
||||||
|
* @param priority - Priority type ('express' = calendar hours, 'standard' = working hours)
|
||||||
* @param enabled - Whether tracking is enabled (default: true)
|
* @param enabled - Whether tracking is enabled (default: true)
|
||||||
* @returns SLAStatus object with real-time updates
|
* @returns SLAStatus object with real-time updates
|
||||||
*/
|
*/
|
||||||
export function useSLATracking(
|
export function useSLATracking(
|
||||||
startDate: string | Date | null | undefined,
|
startDate: string | Date | null | undefined,
|
||||||
deadline: string | Date | null | undefined,
|
deadline: string | Date | null | undefined,
|
||||||
|
priority?: string,
|
||||||
enabled: boolean = true
|
enabled: boolean = true
|
||||||
): SLAStatus | null {
|
): SLAStatus | null {
|
||||||
const [slaStatus, setSlaStatus] = useState<SLAStatus | null>(null);
|
const [slaStatus, setSlaStatus] = useState<SLAStatus | null>(null);
|
||||||
@ -26,7 +28,7 @@ export function useSLATracking(
|
|||||||
// Initial calculation
|
// Initial calculation
|
||||||
const updateStatus = () => {
|
const updateStatus = () => {
|
||||||
try {
|
try {
|
||||||
const status = getSLAStatus(startDate, deadline);
|
const status = getSLAStatus(startDate, deadline, priority);
|
||||||
setSlaStatus(status);
|
setSlaStatus(status);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useSLATracking] Error calculating SLA status:', error);
|
console.error('[useSLATracking] Error calculating SLA status:', error);
|
||||||
@ -39,7 +41,7 @@ export function useSLATracking(
|
|||||||
const interval = setInterval(updateStatus, 60000); // 60 seconds
|
const interval = setInterval(updateStatus, 60000); // 60 seconds
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [startDate, deadline, enabled]);
|
}, [startDate, deadline, priority, enabled]);
|
||||||
|
|
||||||
return slaStatus;
|
return slaStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { searchUsers, type UserSummary } from '@/services/userApi';
|
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||||
import { createWorkflowMultipart, submitWorkflow, getWorkflowDetails, updateWorkflow, updateWorkflowMultipart } from '@/services/workflowApi';
|
import { createWorkflowMultipart, submitWorkflow, getWorkflowDetails, updateWorkflow, updateWorkflowMultipart } from '@/services/workflowApi';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
||||||
import {
|
import {
|
||||||
@ -221,6 +222,19 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
const [loadingDraft, setLoadingDraft] = useState(isEditing);
|
const [loadingDraft, setLoadingDraft] = useState(isEditing);
|
||||||
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
|
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
|
||||||
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
|
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
|
||||||
|
|
||||||
|
// 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
|
// Fetch draft data when in edit mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -392,6 +406,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
formData.approvers.every(approver => {
|
formData.approvers.every(approver => {
|
||||||
if (!approver || !approver.email) return false;
|
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
|
// Check TAT validation based on type
|
||||||
const tatType = approver.tatType || 'hours';
|
const tatType = approver.tatType || 'hours';
|
||||||
if (tatType === 'hours') {
|
if (tatType === 'hours') {
|
||||||
@ -408,8 +432,122 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = async () => {
|
||||||
if (currentStep < totalSteps && isStepValid()) {
|
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);
|
setCurrentStep(currentStep + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -417,6 +555,10 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
const prevStep = () => {
|
const prevStep = () => {
|
||||||
if (currentStep > 1) {
|
if (currentStep > 1) {
|
||||||
setCurrentStep(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
|
// Check if user is already in the target list
|
||||||
if (currentList.find((u: any) => u.id === user.id || (u.email || '').toLowerCase() === userEmail)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -461,7 +608,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
a.id === user.id || (a.email || '').toLowerCase() === userEmail
|
a.id === user.id || (a.email || '').toLowerCase() === userEmail
|
||||||
);
|
);
|
||||||
if (isApprover) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} else if (type === 'approvers') {
|
} else if (type === 'approvers') {
|
||||||
@ -470,7 +622,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
s.id === user.id || (s.email || '').toLowerCase() === userEmail
|
s.id === user.id || (s.email || '').toLowerCase() === userEmail
|
||||||
);
|
);
|
||||||
if (isSpectator) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -524,10 +681,91 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
return newUser;
|
return newUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inviteAndAddUser = (type: 'approvers' | 'spectators' | 'ccList') => {
|
const inviteAndAddUser = async (type: 'approvers' | 'spectators' | 'ccList') => {
|
||||||
const user = addUserByEmail();
|
// For spectators, validate against Okta before adding
|
||||||
if (user) {
|
if (type === 'spectators' && emailInput) {
|
||||||
addUser(user, type);
|
// 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>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
|
<div className="flex items-center justify-between mb-1">
|
||||||
Email Address *
|
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
|
||||||
</Label>
|
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">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id={`approver-${level}`}
|
id={`approver-${level}`}
|
||||||
@ -1414,10 +1666,20 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
const newApprovers = [...formData.approvers];
|
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] = {
|
||||||
...newApprovers[index],
|
...newApprovers[index],
|
||||||
email: value,
|
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);
|
updateFormData('approvers', newApprovers);
|
||||||
|
|
||||||
@ -1457,12 +1719,17 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
<li
|
<li
|
||||||
key={u.userId}
|
key={u.userId}
|
||||||
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
// Check if user is already a spectator
|
// Check if user is already a spectator
|
||||||
const spectatorIds = (formData.spectators || []).map((s: any) => s?.id).filter(Boolean);
|
const spectatorIds = (formData.spectators || []).map((s: any) => s?.id).filter(Boolean);
|
||||||
const spectatorEmails = (formData.spectators || []).map((s: any) => s?.email?.toLowerCase?.()).filter(Boolean);
|
const spectatorEmails = (formData.spectators || []).map((s: any) => s?.email?.toLowerCase?.()).filter(Boolean);
|
||||||
if (spectatorIds.includes(u.userId) || spectatorEmails.includes((u.email || '').toLowerCase())) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1472,16 +1739,39 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
.map((a: any) => a?.email?.toLowerCase?.())
|
.map((a: any) => a?.email?.toLowerCase?.())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (approverEmails.includes((u.email || '').toLowerCase())) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure user exists in database and get the DB userId
|
||||||
|
let dbUserId = u.userId;
|
||||||
|
try {
|
||||||
|
const dbUser = await ensureUserExists({
|
||||||
|
userId: u.userId,
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
firstName: u.firstName,
|
||||||
|
lastName: u.lastName,
|
||||||
|
department: u.department
|
||||||
|
});
|
||||||
|
// Use the database userId (UUID) instead of Okta ID
|
||||||
|
dbUserId = dbUser.userId;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to ensure user exists:', err);
|
||||||
|
// Continue with Okta ID if ensure fails
|
||||||
|
}
|
||||||
|
|
||||||
const updated = [...formData.approvers];
|
const updated = [...formData.approvers];
|
||||||
updated[index] = {
|
updated[index] = {
|
||||||
...updated[index],
|
...updated[index],
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' '),
|
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' '),
|
||||||
userId: u.userId,
|
userId: dbUserId,
|
||||||
level: level,
|
level: level,
|
||||||
};
|
};
|
||||||
updateFormData('approvers', updated);
|
updateFormData('approvers', updated);
|
||||||
@ -1497,10 +1787,27 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
{formData.approvers[index]?.userId ? (
|
||||||
<span className="font-medium">@</span>
|
<p className="text-xs text-green-600 mt-1 flex items-center gap-1">
|
||||||
Use @ sign to tag a user
|
<CheckCircle className="w-3 h-3" />
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Peer Approver Section */}
|
{/* Peer Approver Section */}
|
||||||
@ -1798,9 +2105,11 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
}}
|
}}
|
||||||
onKeyPress={(e) => {
|
onKeyPress={async (e) => {
|
||||||
|
// Allow Enter key to add spectator (will validate)
|
||||||
if (e.key === 'Enter' && validateEmail(emailInput)) {
|
if (e.key === 'Enter' && validateEmail(emailInput)) {
|
||||||
inviteAndAddUser('spectators');
|
e.preventDefault();
|
||||||
|
await inviteAndAddUser('spectators');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="text-sm w-full"
|
className="text-sm w-full"
|
||||||
@ -1815,20 +2124,43 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
<li
|
<li
|
||||||
key={u.userId}
|
key={u.userId}
|
||||||
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
// Check if user is already an approver
|
// Check if user is already an approver
|
||||||
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
|
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
|
||||||
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
|
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
|
||||||
if (approverIds.includes(u.userId) || approverEmails.includes((u.email || '').toLowerCase())) {
|
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('');
|
setEmailInput('');
|
||||||
setSpectatorSearchResults([]);
|
setSpectatorSearchResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure user exists in database and get the DB userId
|
||||||
|
let dbUserId = u.userId;
|
||||||
|
try {
|
||||||
|
const dbUser = await ensureUserExists({
|
||||||
|
userId: u.userId,
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
firstName: u.firstName,
|
||||||
|
lastName: u.lastName,
|
||||||
|
department: u.department
|
||||||
|
});
|
||||||
|
// Use the database userId (UUID) instead of Okta ID
|
||||||
|
dbUserId = dbUser.userId;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to ensure user exists:', err);
|
||||||
|
// Continue with Okta ID if ensure fails
|
||||||
|
}
|
||||||
|
|
||||||
// Add selected spectator directly with precise id/name/email
|
// Add selected spectator directly with precise id/name/email
|
||||||
const spectator = {
|
const spectator = {
|
||||||
id: u.userId,
|
id: dbUserId,
|
||||||
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email.split('@')[0],
|
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email.split('@')[0],
|
||||||
email: u.email,
|
email: u.email,
|
||||||
avatar: (u.displayName || u.email).split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase(),
|
avatar: (u.displayName || u.email).split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase(),
|
||||||
@ -1853,12 +2185,20 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => inviteAndAddUser('spectators')}
|
onClick={async () => {
|
||||||
|
await inviteAndAddUser('spectators');
|
||||||
|
}}
|
||||||
disabled={!validateEmail(emailInput)}
|
disabled={!validateEmail(emailInput)}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||||
@ -2465,23 +2805,23 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-gradient-to-br from-gray-50 to-white">
|
<div className="h-full flex flex-col bg-gradient-to-br from-gray-50 to-white">
|
||||||
{/* Header */}
|
{/* Header - Compact on Mobile */}
|
||||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex-shrink-0">
|
<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">
|
<div className="flex items-center justify-between max-w-6xl mx-auto gap-2 sm:gap-4">
|
||||||
<div className="flex items-center 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">
|
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 h-8 w-8 sm:h-10 sm:w-10">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-base sm:text-xl md:text-2xl font-bold text-gray-900 truncate">
|
||||||
{isEditing ? 'Edit Draft Request' : 'Create New Request'}
|
{isEditing ? 'Edit Draft' : 'New Request'}
|
||||||
</h1>
|
</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]}
|
Step {currentStep} of {totalSteps}: {STEP_NAMES[currentStep - 1]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="hidden md:flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium text-gray-900">{Math.round((currentStep / totalSteps) * 100)}% Complete</p>
|
<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>
|
<p className="text-xs text-gray-600">{totalSteps - currentStep} steps remaining</p>
|
||||||
@ -2490,96 +2830,134 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar - Mobile Optimized */}
|
||||||
<div className="bg-white border-b border-gray-200 px-6 py-3 flex-shrink-0">
|
<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="max-w-6xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-2">
|
{/* Mobile: Current step indicator only */}
|
||||||
{STEP_NAMES.map((_, index) => (
|
<div className="block sm:hidden">
|
||||||
<div key={index} className="flex items-center">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
<div className="flex items-center gap-2">
|
||||||
index + 1 < currentStep
|
<div className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold">
|
||||||
? 'bg-green-600 text-white'
|
{currentStep}
|
||||||
: index + 1 === currentStep
|
</div>
|
||||||
? 'bg-blue-600 text-white'
|
<div>
|
||||||
: 'bg-gray-200 text-gray-600'
|
<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>
|
||||||
{index + 1 < currentStep ? (
|
</div>
|
||||||
<Check className="w-4 h-4" />
|
</div>
|
||||||
) : (
|
<div className="text-right">
|
||||||
index + 1
|
<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>
|
</div>
|
||||||
{index < STEP_NAMES.length - 1 && (
|
))}
|
||||||
<div className={`w-12 lg:w-16 h-1 mx-2 ${
|
</div>
|
||||||
index + 1 < currentStep ? 'bg-green-600' : 'bg-gray-200'
|
<div className="hidden lg:flex justify-between text-xs text-gray-600 mt-2">
|
||||||
}`} />
|
{STEP_NAMES.map((step, index) => (
|
||||||
)}
|
<span key={index} className={`${
|
||||||
</div>
|
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
|
||||||
))}
|
}`}>
|
||||||
</div>
|
{step}
|
||||||
<div className="hidden lg:flex justify-between text-xs text-gray-600 mt-2">
|
</span>
|
||||||
{STEP_NAMES.map((step, index) => (
|
))}
|
||||||
<span key={index} className={`${
|
</div>
|
||||||
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
|
|
||||||
}`}>
|
|
||||||
{step}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - with extra bottom padding for mobile keyboards */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto pb-24 sm:pb-4">
|
||||||
<div className="max-w-6xl mx-auto p-6">
|
<div className="max-w-6xl mx-auto p-3 sm:p-6 pb-6 sm:pb-6">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{renderStepContent()}
|
{renderStepContent()}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer - Fixed on mobile for better keyboard handling */}
|
||||||
<div className="bg-white border-t border-gray-200 px-6 py-4 flex-shrink-0">
|
<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 justify-between items-center max-w-6xl mx-auto">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
disabled={currentStep === 1}
|
disabled={currentStep === 1}
|
||||||
size="lg"
|
size="sm"
|
||||||
|
className="sm:size-lg order-2 sm:order-1"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||||
Previous
|
<span className="text-xs sm:text-sm">Previous</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
{/* Action Buttons */}
|
||||||
<Button variant="outline" onClick={handleSaveDraft} size="lg" disabled={loadingDraft}>
|
<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'}
|
{isEditing ? 'Update Draft' : 'Save Draft'}
|
||||||
</Button>
|
</Button>
|
||||||
{currentStep === totalSteps ? (
|
{currentStep === totalSteps ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isStepValid() || loadingDraft}
|
disabled={!isStepValid() || loadingDraft}
|
||||||
size="lg"
|
size="sm"
|
||||||
className="bg-green-600 hover:bg-green-700 px-8"
|
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" />
|
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||||
Submit Request
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
console.log('Next button clicked!');
|
console.log('Next button clicked!');
|
||||||
console.log('Current step:', currentStep);
|
console.log('Current step:', currentStep);
|
||||||
console.log('Is step valid:', isStepValid());
|
console.log('Is step valid:', isStepValid());
|
||||||
console.log('Form data:', formData);
|
console.log('Form data:', formData);
|
||||||
nextStep();
|
await nextStep();
|
||||||
}}
|
}}
|
||||||
disabled={!isStepValid()}
|
disabled={!isStepValid()}
|
||||||
size="lg"
|
size="sm"
|
||||||
className="px-8"
|
className="sm:size-lg flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
Next Step
|
<span className="hidden sm:inline">Next Step</span>
|
||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
<span className="sm:hidden">Next</span>
|
||||||
|
<ArrowRight className="h-3 w-3 sm:h-4 sm:w-4 ml-1 sm:ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -2592,6 +2970,96 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
onClose={() => setShowTemplateModal(false)}
|
onClose={() => setShowTemplateModal(false)}
|
||||||
onSelectTemplate={handleTemplateSelection}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -20,50 +20,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { SLATracker } from '@/components/sla/SLATracker';
|
// SLATracker removed - not needed on MyRequests (only for OpenRequests where user is approver)
|
||||||
|
|
||||||
interface MyRequestsProps {
|
interface MyRequestsProps {
|
||||||
onViewRequest: (requestId: string, requestTitle?: string) => void;
|
onViewRequest: (requestId: string, requestTitle?: string) => void;
|
||||||
dynamicRequests?: any[];
|
dynamicRequests?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed mock data; list renders API data only
|
|
||||||
|
|
||||||
// Helper to calculate due date from created date and TAT hours
|
|
||||||
const calculateDueDate = (createdAt: string, tatHours: number, priority: string): string => {
|
|
||||||
if (!createdAt || !tatHours) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startDate = new Date(createdAt);
|
|
||||||
|
|
||||||
if (priority === 'express') {
|
|
||||||
// Express: Calendar days (includes weekends)
|
|
||||||
const dueDate = new Date(startDate);
|
|
||||||
dueDate.setHours(dueDate.getHours() + tatHours);
|
|
||||||
return dueDate.toISOString();
|
|
||||||
} else {
|
|
||||||
// Standard: Working days (8 hours per day, skip weekends)
|
|
||||||
let remainingHours = tatHours;
|
|
||||||
let currentDate = new Date(startDate);
|
|
||||||
|
|
||||||
while (remainingHours > 0) {
|
|
||||||
// Skip weekends (Saturday = 6, Sunday = 0)
|
|
||||||
if (currentDate.getDay() !== 0 && currentDate.getDay() !== 6) {
|
|
||||||
const hoursToAdd = Math.min(remainingHours, 8);
|
|
||||||
remainingHours -= hoursToAdd;
|
|
||||||
}
|
|
||||||
if (remainingHours > 0) {
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentDate.toISOString();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating due date:', error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityConfig = (priority: string) => {
|
const getPriorityConfig = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'express':
|
case 'express':
|
||||||
@ -157,15 +120,12 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
// Convert API/dynamic requests to the format expected by this component
|
// Convert API/dynamic requests to the format expected by this component
|
||||||
const sourceRequests = (apiRequests.length ? apiRequests : dynamicRequests);
|
const sourceRequests = (apiRequests.length ? apiRequests : dynamicRequests);
|
||||||
const convertedDynamicRequests = sourceRequests.map((req: any) => {
|
const convertedDynamicRequests = sourceRequests.map((req: any) => {
|
||||||
// Calculate due date
|
|
||||||
const totalTatHours = Number(req.totalTatHours || 0);
|
|
||||||
const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at;
|
const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at;
|
||||||
const priority = (req.priority || '').toString().toLowerCase();
|
const priority = (req.priority || '').toString().toLowerCase();
|
||||||
const calculatedDueDate = calculateDueDate(createdAt, totalTatHours, priority);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: req.requestNumber || req.request_number || req.requestId || req.id || req.request_id, // Use requestNumber as primary identifier
|
id: req.requestNumber || req.request_number || req.requestId || req.id || req.request_id,
|
||||||
requestId: req.requestId || req.id || req.request_id, // Keep requestId for API calls if needed
|
requestId: req.requestId || req.id || req.request_id,
|
||||||
displayId: req.requestNumber || req.request_number || req.id,
|
displayId: req.requestNumber || req.request_number || req.id,
|
||||||
title: req.title,
|
title: req.title,
|
||||||
description: req.description,
|
description: req.description,
|
||||||
@ -176,7 +136,6 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
|
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
|
||||||
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
|
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
|
||||||
dueDate: calculatedDueDate || (req.dueDate ? new Date(req.dueDate).toISOString().split('T')[0] : undefined),
|
|
||||||
templateType: req.templateType,
|
templateType: req.templateType,
|
||||||
templateName: req.templateName
|
templateName: req.templateName
|
||||||
};
|
};
|
||||||
@ -414,28 +373,21 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
||||||
<span className="text-xs sm:text-sm truncate">
|
<span className="text-xs sm:text-sm truncate">
|
||||||
<span className="text-gray-500">Current:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
|
<span className="text-gray-500">Current Approver:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
||||||
<span className="text-xs sm:text-sm">
|
<span className="text-xs sm:text-sm">
|
||||||
<span className="text-gray-500">Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
{/* SLA Tracker with Working Hours */}
|
<span>Submitted: {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}</span>
|
||||||
{request.createdAt && request.dueDate && request.status !== 'approved' && request.status !== 'rejected' && (
|
|
||||||
<div className="pt-3 border-t border-gray-100">
|
|
||||||
<SLATracker
|
|
||||||
startDate={request.createdAt}
|
|
||||||
deadline={request.dueDate}
|
|
||||||
showDetails={true}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -3,13 +3,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, Eye, RefreshCw, Settings2, X } from 'lucide-react';
|
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, Eye, RefreshCw, Settings2, X } from 'lucide-react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateShort } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -17,22 +16,21 @@ interface Request {
|
|||||||
status: 'pending' | 'in-review';
|
status: 'pending' | 'in-review';
|
||||||
priority: 'express' | 'standard';
|
priority: 'express' | 'standard';
|
||||||
initiator: { name: string; avatar: string };
|
initiator: { name: string; avatar: string };
|
||||||
currentApprover?: { name: string; avatar: string };
|
currentApprover?: {
|
||||||
slaProgress: number;
|
name: string;
|
||||||
slaRemaining: string;
|
avatar: string;
|
||||||
|
sla?: any; // Backend-calculated SLA data
|
||||||
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
dueDate?: string;
|
|
||||||
approvalStep?: string;
|
approvalStep?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
totalTatHours?: number;
|
currentLevelSLA?: any; // Backend-provided SLA for current level
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenRequestsProps {
|
interface OpenRequestsProps {
|
||||||
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed static data; will load from API
|
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
const getPriorityConfig = (priority: string) => {
|
const getPriorityConfig = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
@ -80,11 +78,7 @@ const getStatusConfig = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSLAUrgency = (progress: number) => {
|
// getSLAUrgency removed - now using SLATracker component for real-time SLA display
|
||||||
if (progress >= 80) return { color: 'bg-red-500', textColor: 'text-red-600', urgency: 'critical' };
|
|
||||||
if (progress >= 60) return { color: 'bg-orange-500', textColor: 'text-orange-600', urgency: 'warning' };
|
|
||||||
return { color: 'bg-green-500', textColor: 'text-green-600', urgency: 'normal' };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@ -96,42 +90,6 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
const [items, setItems] = useState<Request[]>([]);
|
const [items, setItems] = useState<Request[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Helper to calculate due date from created date and TAT hours
|
|
||||||
const calculateDueDate = (createdAt: string, tatHours: number, priority: string): string => {
|
|
||||||
if (!createdAt || !tatHours) return 'Not set';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startDate = new Date(createdAt);
|
|
||||||
|
|
||||||
if (priority === 'express') {
|
|
||||||
// Express: Calendar days (includes weekends)
|
|
||||||
const dueDate = new Date(startDate);
|
|
||||||
dueDate.setHours(dueDate.getHours() + tatHours);
|
|
||||||
return dueDate.toISOString();
|
|
||||||
} else {
|
|
||||||
// Standard: Working days (8 hours per day, skip weekends)
|
|
||||||
let remainingHours = tatHours;
|
|
||||||
let currentDate = new Date(startDate);
|
|
||||||
|
|
||||||
while (remainingHours > 0) {
|
|
||||||
// Skip weekends (Saturday = 6, Sunday = 0)
|
|
||||||
if (currentDate.getDay() !== 0 && currentDate.getDay() !== 6) {
|
|
||||||
const hoursToAdd = Math.min(remainingHours, 8);
|
|
||||||
remainingHours -= hoursToAdd;
|
|
||||||
}
|
|
||||||
if (remainingHours > 0) {
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentDate.toISOString();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating due date:', error);
|
|
||||||
return 'Error';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -147,30 +105,29 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
: [];
|
: [];
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
const mapped: Request[] = data.map((r: any) => {
|
const mapped: Request[] = data.map((r: any) => {
|
||||||
// Use totalTatHours directly from backend (already calculated)
|
|
||||||
const totalTatHours = Number(r.totalTatHours || 0);
|
|
||||||
|
|
||||||
const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at;
|
const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at;
|
||||||
const dueDate = calculateDueDate(createdAt, totalTatHours, (r.priority || '').toString().toLowerCase());
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
|
id: r.requestNumber || r.request_number || r.requestId,
|
||||||
requestId: r.requestId, // Keep requestId for reference
|
requestId: r.requestId,
|
||||||
// keep a display id for UI
|
|
||||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',
|
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',
|
||||||
priority: (r.priority || '').toString().toLowerCase(),
|
priority: (r.priority || '').toString().toLowerCase(),
|
||||||
initiator: { name: (r.initiator?.displayName || r.initiator?.email || '—'), avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) },
|
initiator: {
|
||||||
currentApprover: r.currentApprover ? { name: (r.currentApprover.name || r.currentApprover.email || '—'), avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) } : undefined,
|
name: (r.initiator?.displayName || r.initiator?.email || '—'),
|
||||||
slaProgress: Number(r.sla?.percent || 0),
|
avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase())
|
||||||
slaRemaining: r.sla?.remainingText || '—',
|
},
|
||||||
|
currentApprover: r.currentApprover ? {
|
||||||
|
name: (r.currentApprover.name || r.currentApprover.email || '—'),
|
||||||
|
avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()),
|
||||||
|
sla: r.currentApprover.sla // ← Backend-calculated SLA
|
||||||
|
} : undefined,
|
||||||
createdAt: createdAt || '—',
|
createdAt: createdAt || '—',
|
||||||
dueDate: dueDate !== 'Not set' && dueDate !== 'Error' ? dueDate : undefined,
|
|
||||||
approvalStep: r.currentLevel ? `Step ${r.currentLevel} of ${r.totalLevels || '?'}` : undefined,
|
approvalStep: r.currentLevel ? `Step ${r.currentLevel} of ${r.totalLevels || '?'}` : undefined,
|
||||||
department: r.department,
|
department: r.department,
|
||||||
totalTatHours
|
currentLevelSLA: r.currentLevelSLA, // ← Backend-calculated current level SLA
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setItems(mapped);
|
setItems(mapped);
|
||||||
@ -204,8 +161,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
bValue = new Date(b.createdAt);
|
bValue = new Date(b.createdAt);
|
||||||
break;
|
break;
|
||||||
case 'due':
|
case 'due':
|
||||||
aValue = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
aValue = a.currentLevelSLA?.deadline ? new Date(a.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
bValue = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
bValue = b.currentLevelSLA?.deadline ? new Date(b.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
break;
|
break;
|
||||||
case 'priority':
|
case 'priority':
|
||||||
const priorityOrder = { express: 2, standard: 1 };
|
const priorityOrder = { express: 2, standard: 1 };
|
||||||
@ -213,8 +170,9 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
bValue = priorityOrder[b.priority as keyof typeof priorityOrder];
|
bValue = priorityOrder[b.priority as keyof typeof priorityOrder];
|
||||||
break;
|
break;
|
||||||
case 'sla':
|
case 'sla':
|
||||||
aValue = a.slaProgress;
|
// Sort by SLA percentage (most urgent first)
|
||||||
bValue = b.slaProgress;
|
aValue = a.currentLevelSLA?.percentageUsed || 0;
|
||||||
|
bValue = b.currentLevelSLA?.percentageUsed || 0;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
@ -389,7 +347,6 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
{filteredAndSortedRequests.map((request) => {
|
{filteredAndSortedRequests.map((request) => {
|
||||||
const priorityConfig = getPriorityConfig(request.priority);
|
const priorityConfig = getPriorityConfig(request.priority);
|
||||||
const statusConfig = getStatusConfig(request.status);
|
const statusConfig = getStatusConfig(request.status);
|
||||||
const slaConfig = getSLAUrgency(request.slaProgress);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -447,29 +404,61 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SLA Progress */}
|
{/* SLA Display - Shows backend-calculated SLA */}
|
||||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
|
{request.currentLevelSLA && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
<div className="pt-3 border-t border-gray-100">
|
||||||
<div className="flex items-center gap-2">
|
<div className={`p-3 rounded-lg ${
|
||||||
<Clock className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-500 flex-shrink-0" />
|
request.currentLevelSLA.status === 'breached' ? 'bg-red-50 border border-red-200' :
|
||||||
<span className="text-xs sm:text-sm font-medium text-gray-700">SLA Progress</span>
|
request.currentLevelSLA.status === 'critical' ? 'bg-orange-50 border border-orange-200' :
|
||||||
</div>
|
request.currentLevelSLA.status === 'approaching' ? 'bg-yellow-50 border border-yellow-200' :
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
'bg-green-50 border border-green-200'
|
||||||
<span className={`text-xs sm:text-sm font-semibold ${slaConfig.textColor}`}>
|
}`}>
|
||||||
{request.slaRemaining} remaining
|
<div className="flex items-center justify-between mb-2">
|
||||||
</span>
|
<div className="flex items-center gap-2">
|
||||||
{slaConfig.urgency === 'critical' && (
|
<Clock className="w-4 h-4 text-gray-600" />
|
||||||
<Badge variant="destructive" className="animate-pulse text-xs shrink-0">
|
<span className="text-sm font-medium text-gray-900">SLA Progress</span>
|
||||||
URGENT
|
</div>
|
||||||
|
<Badge className={`text-xs ${
|
||||||
|
request.currentLevelSLA.status === 'breached' ? 'bg-red-600 text-white' :
|
||||||
|
request.currentLevelSLA.status === 'critical' ? 'bg-orange-600 text-white' :
|
||||||
|
request.currentLevelSLA.status === 'approaching' ? 'bg-yellow-600 text-white' :
|
||||||
|
'bg-green-600 text-white'
|
||||||
|
}`}>
|
||||||
|
{request.currentLevelSLA.percentageUsed}%
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={request.currentLevelSLA.percentageUsed}
|
||||||
|
className={`h-2 mb-2 ${
|
||||||
|
request.currentLevelSLA.status === 'breached' ? '[&>div]:bg-red-600' :
|
||||||
|
request.currentLevelSLA.status === 'critical' ? '[&>div]:bg-orange-600' :
|
||||||
|
request.currentLevelSLA.status === 'approaching' ? '[&>div]:bg-yellow-600' :
|
||||||
|
'[&>div]:bg-green-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{request.currentLevelSLA.elapsedText} elapsed
|
||||||
|
</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
request.currentLevelSLA.status === 'breached' ? 'text-red-600' :
|
||||||
|
request.currentLevelSLA.status === 'critical' ? 'text-orange-600' :
|
||||||
|
'text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{request.currentLevelSLA.remainingText} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{request.currentLevelSLA.deadline && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Due: {new Date(request.currentLevelSLA.deadline).toLocaleString()}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
)}
|
||||||
value={request.slaProgress}
|
|
||||||
className="h-2 sm:h-3 bg-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Info */}
|
{/* Status Info */}
|
||||||
<div className="flex items-center gap-2 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg">
|
<div className="flex items-center gap-2 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg">
|
||||||
@ -519,7 +508,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3 flex-shrink-0" />
|
<Clock className="w-3 h-3 flex-shrink-0" />
|
||||||
<span className="truncate">Due: {request.dueDate ? formatDateShort(request.dueDate) : 'Not set'}</span>
|
<span className="truncate">Due: {request.currentLevelSLA?.deadline ? formatDateShort(request.currentLevelSLA.deadline) : 'Not set'}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,14 +3,14 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||||
import { FilePreview } from '@/components/common/FilePreview';
|
import { FilePreview } from '@/components/common/FilePreview';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||||
import workflowApi, { approveLevel, rejectLevel, addApprover, addApproverAtLevel, skipApprover, addSpectator, downloadDocument, getDocumentPreviewUrl } from '@/services/workflowApi';
|
import workflowApi, { approveLevel, rejectLevel, addApproverAtLevel, skipApprover, addSpectator, downloadDocument, getDocumentPreviewUrl } from '@/services/workflowApi';
|
||||||
import { uploadDocument } from '@/services/documentApi';
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
import { ApprovalModal } from '@/components/approval/ApprovalModal/ApprovalModal';
|
import { ApprovalModal } from '@/components/approval/ApprovalModal/ApprovalModal';
|
||||||
import { RejectionModal } from '@/components/approval/RejectionModal/RejectionModal';
|
import { RejectionModal } from '@/components/approval/RejectionModal/RejectionModal';
|
||||||
@ -58,11 +58,11 @@ class RequestDetailErrorBoundary extends Component<
|
|||||||
return { hasError: true, error };
|
return { hasError: true, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
console.error('RequestDetail Error:', error, errorInfo);
|
console.error('RequestDetail Error:', error, errorInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
override render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
||||||
@ -144,28 +144,6 @@ const getStatusConfig = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSLAConfig = (progress: number) => {
|
|
||||||
if (progress >= 80) {
|
|
||||||
return {
|
|
||||||
bg: 'bg-red-50',
|
|
||||||
color: 'bg-red-500',
|
|
||||||
textColor: 'text-red-700'
|
|
||||||
};
|
|
||||||
} else if (progress >= 60) {
|
|
||||||
return {
|
|
||||||
bg: 'bg-orange-50',
|
|
||||||
color: 'bg-orange-500',
|
|
||||||
textColor: 'text-orange-700'
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
bg: 'bg-green-50',
|
|
||||||
color: 'bg-green-500',
|
|
||||||
textColor: 'text-green-700'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStepIcon = (status: string) => {
|
const getStepIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
@ -233,45 +211,8 @@ function RequestDetailInner({
|
|||||||
const [unreadWorkNotes, setUnreadWorkNotes] = useState(0);
|
const [unreadWorkNotes, setUnreadWorkNotes] = useState(0);
|
||||||
const [mergedMessages, setMergedMessages] = useState<any[]>([]);
|
const [mergedMessages, setMergedMessages] = useState<any[]>([]);
|
||||||
const [workNoteAttachments, setWorkNoteAttachments] = useState<any[]>([]);
|
const [workNoteAttachments, setWorkNoteAttachments] = useState<any[]>([]);
|
||||||
const fileInputRef = useState<HTMLInputElement | null>(null)[0];
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Helper to calculate due date from created date and TAT hours
|
|
||||||
const calculateDueDate = (createdAt: string, tatHours: number, priority: string): string => {
|
|
||||||
if (!createdAt || !tatHours) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startDate = new Date(createdAt);
|
|
||||||
|
|
||||||
if (priority === 'express') {
|
|
||||||
// Express: Calendar days (includes weekends)
|
|
||||||
const dueDate = new Date(startDate);
|
|
||||||
dueDate.setHours(dueDate.getHours() + tatHours);
|
|
||||||
return dueDate.toISOString();
|
|
||||||
} else {
|
|
||||||
// Standard: Working days (8 hours per day, skip weekends)
|
|
||||||
let remainingHours = tatHours;
|
|
||||||
let currentDate = new Date(startDate);
|
|
||||||
|
|
||||||
while (remainingHours > 0) {
|
|
||||||
// Skip weekends (Saturday = 6, Sunday = 0)
|
|
||||||
if (currentDate.getDay() !== 0 && currentDate.getDay() !== 6) {
|
|
||||||
const hoursToAdd = Math.min(remainingHours, 8);
|
|
||||||
remainingHours -= hoursToAdd;
|
|
||||||
}
|
|
||||||
if (remainingHours > 0) {
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentDate.toISOString();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating due date:', error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shared refresh routine
|
// Shared refresh routine
|
||||||
const refreshDetails = async () => {
|
const refreshDetails = async () => {
|
||||||
try {
|
try {
|
||||||
@ -345,9 +286,12 @@ function RequestDetailInner({
|
|||||||
approverEmail: a.approverEmail,
|
approverEmail: a.approverEmail,
|
||||||
tatHours: Number(a.tatHours || 0),
|
tatHours: Number(a.tatHours || 0),
|
||||||
elapsedHours: Number(a.elapsedHours || 0),
|
elapsedHours: Number(a.elapsedHours || 0),
|
||||||
|
remainingHours: Number(a.remainingHours || 0),
|
||||||
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
|
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
|
||||||
comment: a.comments || undefined,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
|
levelStartTime: a.levelStartTime || a.tatStartTime,
|
||||||
tatAlerts: levelAlerts,
|
tatAlerts: levelAlerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -384,15 +328,6 @@ function RequestDetailInner({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate total TAT hours and due date
|
|
||||||
const totalTatHours = approvals.reduce((sum: number, a: any) => {
|
|
||||||
return sum + Number(a.tatHours || a.tat_hours || 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const createdAt = wf.submittedAt || wf.submitted_at || wf.createdAt || wf.created_at;
|
|
||||||
const priority = (wf.priority || '').toString().toLowerCase();
|
|
||||||
const calculatedDueDate = calculateDueDate(createdAt, totalTatHours, priority);
|
|
||||||
|
|
||||||
const updatedRequest = {
|
const updatedRequest = {
|
||||||
...wf,
|
...wf,
|
||||||
id: wf.requestNumber || wf.requestId,
|
id: wf.requestNumber || wf.requestId,
|
||||||
@ -401,16 +336,13 @@ function RequestDetailInner({
|
|||||||
title: wf.title,
|
title: wf.title,
|
||||||
description: wf.description,
|
description: wf.description,
|
||||||
status: statusMap(wf.status),
|
status: statusMap(wf.status),
|
||||||
priority: priority,
|
priority: (wf.priority || '').toString().toLowerCase(),
|
||||||
slaProgress: Number(summary?.sla?.percent || 0),
|
|
||||||
slaRemaining: summary?.sla?.remainingText || '—',
|
|
||||||
slaEndDate: calculatedDueDate || undefined,
|
|
||||||
approvalFlow,
|
approvalFlow,
|
||||||
approvals,
|
approvals,
|
||||||
participants,
|
participants,
|
||||||
documents: mappedDocuments,
|
documents: mappedDocuments,
|
||||||
spectators,
|
spectators,
|
||||||
summary,
|
summary, // ← Backend provides SLA in summary.sla
|
||||||
initiator: {
|
initiator: {
|
||||||
name: wf.initiator?.displayName || wf.initiator?.email,
|
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||||
role: wf.initiator?.designation || undefined,
|
role: wf.initiator?.designation || undefined,
|
||||||
@ -523,6 +455,10 @@ function RequestDetailInner({
|
|||||||
setUploadingDocument(true);
|
setUploadingDocument(true);
|
||||||
try {
|
try {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
|
if (!file) {
|
||||||
|
alert('No file selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get UUID requestId (not request number) from current request
|
// Get UUID requestId (not request number) from current request
|
||||||
const requestId = apiRequest?.requestId;
|
const requestId = apiRequest?.requestId;
|
||||||
@ -779,9 +715,12 @@ function RequestDetailInner({
|
|||||||
approverEmail: a.approverEmail,
|
approverEmail: a.approverEmail,
|
||||||
tatHours: Number(a.tatHours || 0),
|
tatHours: Number(a.tatHours || 0),
|
||||||
elapsedHours: Number(a.elapsedHours || 0),
|
elapsedHours: Number(a.elapsedHours || 0),
|
||||||
|
remainingHours: Number(a.remainingHours || 0),
|
||||||
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
|
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
|
||||||
comment: a.comments || undefined,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
|
levelStartTime: a.levelStartTime || a.tatStartTime,
|
||||||
tatAlerts: levelAlerts,
|
tatAlerts: levelAlerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -826,14 +765,6 @@ function RequestDetailInner({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate total TAT hours and due date
|
|
||||||
const totalTatHours = approvals.reduce((sum: number, a: any) => {
|
|
||||||
return sum + Number(a.tatHours || a.tat_hours || 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const createdAt = wf.submittedAt || wf.submitted_at || wf.createdAt || wf.created_at;
|
|
||||||
const calculatedDueDate = calculateDueDate(createdAt, totalTatHours, priority);
|
|
||||||
|
|
||||||
const mapped = {
|
const mapped = {
|
||||||
id: wf.requestNumber || wf.requestId,
|
id: wf.requestNumber || wf.requestId,
|
||||||
requestId: wf.requestId, // ← UUID for API calls
|
requestId: wf.requestId, // ← UUID for API calls
|
||||||
@ -841,9 +772,7 @@ function RequestDetailInner({
|
|||||||
description: wf.description,
|
description: wf.description,
|
||||||
priority,
|
priority,
|
||||||
status: statusMap(wf.status),
|
status: statusMap(wf.status),
|
||||||
slaProgress: Number(summary?.sla?.percent || 0),
|
summary, // ← Backend provides comprehensive SLA in summary.sla
|
||||||
slaRemaining: summary?.sla?.remainingText || '—',
|
|
||||||
slaEndDate: calculatedDueDate || undefined,
|
|
||||||
initiator: {
|
initiator: {
|
||||||
name: wf.initiator?.displayName || wf.initiator?.email,
|
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||||
role: wf.initiator?.designation || undefined,
|
role: wf.initiator?.designation || undefined,
|
||||||
@ -857,6 +786,7 @@ function RequestDetailInner({
|
|||||||
totalSteps: wf.totalLevels,
|
totalSteps: wf.totalLevels,
|
||||||
currentStep: summary?.currentLevel || wf.currentLevel,
|
currentStep: summary?.currentLevel || wf.currentLevel,
|
||||||
approvalFlow,
|
approvalFlow,
|
||||||
|
approvals, // ← Added: Include raw approvals array with levelStartTime/tatStartTime
|
||||||
documents: mappedDocuments,
|
documents: mappedDocuments,
|
||||||
spectators,
|
spectators,
|
||||||
auditTrail: Array.isArray(details.activities) ? details.activities : [],
|
auditTrail: Array.isArray(details.activities) ? details.activities : [],
|
||||||
@ -905,6 +835,14 @@ function RequestDetailInner({
|
|||||||
return null;
|
return null;
|
||||||
}, [requestIdentifier, dynamicRequests, apiRequest]);
|
}, [requestIdentifier, dynamicRequests, apiRequest]);
|
||||||
|
|
||||||
|
// Check if current user is the initiator
|
||||||
|
const isInitiator = useMemo(() => {
|
||||||
|
if (!request || !user) return false;
|
||||||
|
const userEmail = (user as any)?.email?.toLowerCase();
|
||||||
|
const initiatorEmail = request.initiator?.email?.toLowerCase();
|
||||||
|
return userEmail === initiatorEmail;
|
||||||
|
}, [request, user]);
|
||||||
|
|
||||||
// Get all existing participants for validation
|
// Get all existing participants for validation
|
||||||
const existingParticipants = useMemo(() => {
|
const existingParticipants = useMemo(() => {
|
||||||
if (!request) return [];
|
if (!request) return [];
|
||||||
@ -991,7 +929,6 @@ function RequestDetailInner({
|
|||||||
|
|
||||||
const priorityConfig = getPriorityConfig(request.priority || 'standard');
|
const priorityConfig = getPriorityConfig(request.priority || 'standard');
|
||||||
const statusConfig = getStatusConfig(request.status || 'pending');
|
const statusConfig = getStatusConfig(request.status || 'pending');
|
||||||
const slaConfig = getSLAConfig(request.slaProgress || 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -1042,21 +979,78 @@ function RequestDetailInner({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SLA Progress */}
|
{/* SLA Progress Section - Shows OVERALL request SLA from backend */}
|
||||||
<div className={`${slaConfig.bg} px-3 sm:px-4 md:px-6 py-3 sm:py-4`}>
|
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
{(() => {
|
||||||
<div className="flex items-center gap-2">
|
const sla = request.summary?.sla || request.sla;
|
||||||
<Clock className={`h-3.5 w-3.5 sm:h-4 sm:w-4 ${slaConfig.textColor} flex-shrink-0`} />
|
|
||||||
<span className="text-xs sm:text-sm font-medium text-gray-900">SLA Progress</span>
|
if (!sla || request.status === 'approved' || request.status === 'rejected') {
|
||||||
</div>
|
return (
|
||||||
<span className={`text-xs sm:text-sm font-semibold ${slaConfig.textColor}`}>
|
<div className="flex items-center gap-2">
|
||||||
{request.slaRemaining}
|
<Clock className="h-4 w-4 text-gray-500" />
|
||||||
</span>
|
<span className="text-sm font-medium text-gray-700">
|
||||||
</div>
|
{request.status === 'approved' ? '✅ Request Approved' :
|
||||||
<Progress value={request.slaProgress} className="h-2 mb-2" />
|
request.status === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'}
|
||||||
<p className="text-[10px] sm:text-xs text-gray-600">
|
</span>
|
||||||
Due: {request.slaEndDate ? formatDateTime(request.slaEndDate) : 'Not set'} • {request.slaProgress}% elapsed
|
</div>
|
||||||
</p>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="text-sm font-semibold text-gray-900">SLA Progress</span>
|
||||||
|
</div>
|
||||||
|
<Badge className={`text-xs ${
|
||||||
|
sla.status === 'breached' ? 'bg-red-600 text-white animate-pulse' :
|
||||||
|
sla.status === 'critical' ? 'bg-orange-600 text-white' :
|
||||||
|
sla.status === 'approaching' ? 'bg-yellow-600 text-white' :
|
||||||
|
'bg-green-600 text-white'
|
||||||
|
}`}>
|
||||||
|
{sla.percentageUsed || 0}% elapsed
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={sla.percentageUsed || 0}
|
||||||
|
className={`h-3 mb-2 ${
|
||||||
|
sla.status === 'breached' ? '[&>div]:bg-red-600' :
|
||||||
|
sla.status === 'critical' ? '[&>div]:bg-orange-600' :
|
||||||
|
sla.status === 'approaching' ? '[&>div]:bg-yellow-600' :
|
||||||
|
'[&>div]:bg-green-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed
|
||||||
|
</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
sla.status === 'breached' ? 'text-red-600' :
|
||||||
|
sla.status === 'critical' ? 'text-orange-600' :
|
||||||
|
'text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{sla.remainingText || `${sla.remainingHours || 0}h`} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sla.deadline && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Due: {new Date(sla.deadline).toLocaleString()} • {sla.percentageUsed || 0}% elapsed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sla.status === 'critical' && (
|
||||||
|
<p className="text-xs text-orange-600 font-semibold mt-1">⚠️ Approaching Deadline</p>
|
||||||
|
)}
|
||||||
|
{sla.status === 'breached' && (
|
||||||
|
<p className="text-xs text-red-600 font-semibold mt-1">🔴 URGENT - Deadline Passed</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1254,21 +1248,30 @@ function RequestDetailInner({
|
|||||||
Track the approval progress through each step
|
Track the approval progress through each step
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{request.totalSteps && (
|
{request.totalSteps && (() => {
|
||||||
<Badge variant="outline" className="font-medium text-xs sm:text-sm shrink-0">
|
const completedCount = request.approvalFlow?.filter((s: any) => s.status === 'approved').length || 0;
|
||||||
Step {request.currentStep} of {request.totalSteps}
|
return (
|
||||||
</Badge>
|
<Badge variant="outline" className="font-medium text-xs sm:text-sm shrink-0">
|
||||||
)}
|
Step {request.currentStep} of {request.totalSteps} - {completedCount} completed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{request.approvalFlow && request.approvalFlow.length > 0 ? (
|
{request.approvalFlow && request.approvalFlow.length > 0 ? (
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
{request.approvalFlow.map((step: any, index: number) => {
|
{request.approvalFlow.map((step: any, index: number) => {
|
||||||
const isActive = step.status === 'pending' || step.status === 'in-review';
|
const isActive = step.status === 'pending' || step.status === 'in-review';
|
||||||
const isCompleted = step.status === 'approved';
|
const isCompleted = step.status === 'approved';
|
||||||
const isRejected = step.status === 'rejected';
|
const isRejected = step.status === 'rejected';
|
||||||
const isWaiting = step.status === 'waiting';
|
const isWaiting = step.status === 'waiting';
|
||||||
|
|
||||||
|
// Get approval details with backend-calculated SLA
|
||||||
|
const approval = request.approvals?.find((a: any) => a.levelId === step.levelId);
|
||||||
|
const tatHours = Number(step.tatHours || 0);
|
||||||
|
const actualHours = step.actualHours;
|
||||||
|
const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -1297,14 +1300,15 @@ function RequestDetailInner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4 mb-2">
|
{/* Header with Approver Label and Status */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4 mb-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-1">
|
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
|
||||||
<h4 className="font-semibold text-gray-900 text-sm sm:text-base">
|
<h4 className="font-semibold text-gray-900 text-base sm:text-lg">
|
||||||
{step.step ? `Step ${step.step}: ` : ''}{step.role}
|
Approver {index + 1}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="outline" className={`text-xs shrink-0 ${
|
<Badge variant="outline" className={`text-xs shrink-0 capitalize ${
|
||||||
isActive ? 'bg-blue-100 text-blue-800 border-blue-200' :
|
isActive ? 'bg-yellow-100 text-yellow-800 border-yellow-200' :
|
||||||
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
|
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
|
||||||
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
|
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
|
||||||
isWaiting ? 'bg-gray-200 text-gray-600 border-gray-300' :
|
isWaiting ? 'bg-gray-200 text-gray-600 border-gray-300' :
|
||||||
@ -1312,25 +1316,139 @@ function RequestDetailInner({
|
|||||||
}`}>
|
}`}>
|
||||||
{step.status}
|
{step.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{isCompleted && actualHours && (
|
||||||
|
<Badge className="bg-green-600 text-white text-xs">
|
||||||
|
{actualHours.toFixed(1)} hours
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm text-gray-600 truncate">{step.approver}</p>
|
<p className="text-sm font-semibold text-gray-900">{step.approver}</p>
|
||||||
|
<p className="text-xs text-gray-600">{step.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left sm:text-right flex-shrink-0">
|
<div className="text-left sm:text-right flex-shrink-0">
|
||||||
{step.tatHours && (
|
<p className="text-xs text-gray-500 font-medium">Turnaround Time (TAT)</p>
|
||||||
<p className="text-xs text-gray-500">TAT: {step.tatHours}h</p>
|
<p className="text-lg font-bold text-gray-900">{tatHours} hours</p>
|
||||||
)}
|
|
||||||
{step.elapsedHours !== undefined && step.elapsedHours > 0 && (
|
|
||||||
<p className="text-xs text-gray-600 font-medium">Elapsed: {step.elapsedHours}h</p>
|
|
||||||
)}
|
|
||||||
{step.actualHours !== undefined && (
|
|
||||||
<p className="text-xs text-gray-600 font-medium">Done: {step.actualHours.toFixed(2)}h</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step.comment && (
|
{/* Completed Approver - Show Completion Details */}
|
||||||
<div className="mt-2 sm:mt-3 p-2 sm:p-3 bg-white rounded-lg border border-gray-300">
|
{isCompleted && actualHours !== undefined && (
|
||||||
<p className="text-xs sm:text-sm text-gray-700 whitespace-pre-line leading-relaxed break-words">{step.comment}</p>
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">Completed:</span>
|
||||||
|
<span className="font-medium text-gray-900">{step.timestamp ? formatDateTime(step.timestamp) : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">Completed in:</span>
|
||||||
|
<span className="font-medium text-gray-900">{actualHours.toFixed(1)} hours</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for Completed */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress value={100} className="h-2 bg-gray-200" />
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-green-600 font-semibold">Within TAT</span>
|
||||||
|
{savedHours > 0 && (
|
||||||
|
<span className="text-green-600 font-semibold">Saved {savedHours.toFixed(1)} hours</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conclusion Remark */}
|
||||||
|
{step.comment && (
|
||||||
|
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-2">💬 Conclusion Remark:</p>
|
||||||
|
<p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Approver - Show Real-time Progress from Backend */}
|
||||||
|
{isActive && approval?.sla && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">Due by:</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{approval.sla.deadline ? formatDateTime(approval.sla.deadline) : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Approver - Time Tracking */}
|
||||||
|
<div className={`border rounded-lg p-3 ${
|
||||||
|
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
|
||||||
|
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
|
||||||
|
'bg-yellow-50 border-yellow-200'
|
||||||
|
}`}>
|
||||||
|
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Current Approver - Time Tracking
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs mb-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Time elapsed since assigned:</span>
|
||||||
|
<span className="font-medium text-gray-900">{approval.sla.elapsedText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Time used:</span>
|
||||||
|
<span className="font-medium text-gray-900">{approval.sla.elapsedText} / {tatHours}h allocated</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress
|
||||||
|
value={approval.sla.percentageUsed}
|
||||||
|
className={`h-3 ${
|
||||||
|
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
|
||||||
|
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
|
||||||
|
'[&>div]:bg-yellow-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-xs font-semibold ${
|
||||||
|
approval.sla.status === 'breached' ? 'text-red-600' :
|
||||||
|
approval.sla.status === 'critical' ? 'text-orange-600' :
|
||||||
|
'text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
Progress: {approval.sla.percentageUsed}% of TAT used
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{approval.sla.remainingText} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{approval.sla.status === 'breached' && (
|
||||||
|
<p className="text-xs font-semibold text-center text-red-600">
|
||||||
|
🔴 Deadline Breached
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{approval.sla.status === 'critical' && (
|
||||||
|
<p className="text-xs font-semibold text-center text-orange-600">
|
||||||
|
⚠️ Approaching Deadline
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting Approver - Show Assignment Info */}
|
||||||
|
{isWaiting && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-600 mb-1">⏸️ Awaiting Previous Approval</p>
|
||||||
|
<p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rejected Status */}
|
||||||
|
{isRejected && step.comment && (
|
||||||
|
<div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
||||||
|
<p className="text-xs font-semibold text-red-700 mb-2">❌ Rejection Reason:</p>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1448,8 +1566,8 @@ function RequestDetailInner({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skip Approver Button - Only show for pending/in-review levels */}
|
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
|
||||||
{(isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && (
|
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && (
|
||||||
<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"
|
||||||
@ -1601,14 +1719,6 @@ function RequestDetailInner({
|
|||||||
{workNoteAttachments && workNoteAttachments.length > 0 ? (
|
{workNoteAttachments && workNoteAttachments.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{workNoteAttachments.map((file: any, index: number) => {
|
{workNoteAttachments.map((file: any, index: number) => {
|
||||||
const fileType = (file.type || '').toLowerCase();
|
|
||||||
const displayType = fileType.includes('pdf') ? 'PDF' :
|
|
||||||
fileType.includes('excel') || fileType.includes('spreadsheet') ? 'Excel' :
|
|
||||||
fileType.includes('word') || fileType.includes('document') ? 'Word' :
|
|
||||||
fileType.includes('powerpoint') || fileType.includes('presentation') ? 'PowerPoint' :
|
|
||||||
fileType.includes('image') || fileType.includes('jpg') || fileType.includes('png') ? 'Image' :
|
|
||||||
'File';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.attachmentId || index}
|
key={file.attachmentId || index}
|
||||||
@ -1637,7 +1747,6 @@ function RequestDetailInner({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const { getWorkNoteAttachmentPreviewUrl } = require('@/services/workflowApi');
|
|
||||||
setPreviewDocument({
|
setPreviewDocument({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileType: file.type,
|
fileType: file.type,
|
||||||
@ -1759,7 +1868,8 @@ function RequestDetailInner({
|
|||||||
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
|
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{!isSpectator && (
|
{/* Only initiator can add approvers */}
|
||||||
|
{isInitiator && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
|
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
@ -1769,6 +1879,7 @@ function RequestDetailInner({
|
|||||||
Add Approver
|
Add Approver
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Non-spectators can add spectators */}
|
||||||
{!isSpectator && (
|
{!isSpectator && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
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;
|
||||||
|
|
||||||
@ -17,6 +17,23 @@ export async function searchUsers(query: string, limit: number = 10): Promise<Us
|
|||||||
return data as UserSummary[];
|
return data as UserSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { searchUsers };
|
/**
|
||||||
|
* Ensure user exists in database (creates if not exists)
|
||||||
|
* Call this when a user is selected/tagged to pre-create their record
|
||||||
|
*/
|
||||||
|
export async function ensureUserExists(userData: {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
department?: string;
|
||||||
|
phone?: string;
|
||||||
|
}): Promise<UserSummary> {
|
||||||
|
const res = await apiClient.post('/users/ensure', userData);
|
||||||
|
return (res.data?.data || res.data) as UserSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { searchUsers, ensureUserExists };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -35,17 +35,22 @@ ensureConfigLoaded().catch(() => {});
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current time is within working hours
|
* Check if current time is within working hours
|
||||||
|
* @param date - Date to check
|
||||||
|
* @param priority - Priority type ('express' includes weekends, 'standard' excludes weekends)
|
||||||
*/
|
*/
|
||||||
export function isWorkingTime(date: Date = new Date()): boolean {
|
export function isWorkingTime(date: Date = new Date(), priority: string = 'standard'): boolean {
|
||||||
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
|
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
|
||||||
const hour = date.getHours();
|
const hour = date.getHours();
|
||||||
|
|
||||||
// Weekend check
|
// For standard priority: exclude weekends
|
||||||
if (day < WORK_START_DAY || day > WORK_END_DAY) {
|
// For express priority: include weekends (calendar days)
|
||||||
return false;
|
if (priority === 'standard') {
|
||||||
|
if (day < WORK_START_DAY || day > WORK_END_DAY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Working hours check
|
// Working hours check (applies to both priorities)
|
||||||
if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) {
|
if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -57,26 +62,30 @@ export function isWorkingTime(date: Date = new Date()): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get next working time from a given date
|
* Get next working time from a given date
|
||||||
|
* @param date - Current date
|
||||||
|
* @param priority - Priority type ('express' includes weekends, 'standard' excludes weekends)
|
||||||
*/
|
*/
|
||||||
export function getNextWorkingTime(date: Date = new Date()): Date {
|
export function getNextWorkingTime(date: Date = new Date(), priority: string = 'standard'): Date {
|
||||||
const result = new Date(date);
|
const result = new Date(date);
|
||||||
|
|
||||||
// If already in working time, return as is
|
// If already in working time, return as is
|
||||||
if (isWorkingTime(result)) {
|
if (isWorkingTime(result, priority)) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's weekend, move to next Monday
|
// For standard priority: skip weekends
|
||||||
const day = result.getDay();
|
if (priority === 'standard') {
|
||||||
if (day === 0) { // Sunday
|
const day = result.getDay();
|
||||||
result.setDate(result.getDate() + 1);
|
if (day === 0) { // Sunday
|
||||||
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
result.setDate(result.getDate() + 1);
|
||||||
return result;
|
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
||||||
}
|
return result;
|
||||||
if (day === 6) { // Saturday
|
}
|
||||||
result.setDate(result.getDate() + 2);
|
if (day === 6) { // Saturday
|
||||||
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
result.setDate(result.getDate() + 2);
|
||||||
return result;
|
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If before work hours, move to work start
|
// If before work hours, move to work start
|
||||||
@ -89,39 +98,47 @@ export function getNextWorkingTime(date: Date = new Date()): Date {
|
|||||||
if (result.getHours() >= WORK_END_HOUR) {
|
if (result.getHours() >= WORK_END_HOUR) {
|
||||||
result.setDate(result.getDate() + 1);
|
result.setDate(result.getDate() + 1);
|
||||||
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
||||||
// Check if next day is weekend
|
// Check if next day is weekend (only for standard priority)
|
||||||
return getNextWorkingTime(result);
|
return getNextWorkingTime(result, priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate elapsed working hours between two dates
|
* Calculate elapsed working hours between two dates with minute precision
|
||||||
|
* @param startDate - Start date
|
||||||
|
* @param endDate - End date (defaults to now)
|
||||||
|
* @param priority - Priority type ('express' includes weekends, 'standard' excludes weekends)
|
||||||
*/
|
*/
|
||||||
export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = new Date()): number {
|
export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = new Date(), priority: string = 'standard'): number {
|
||||||
let current = new Date(startDate);
|
let current = new Date(startDate);
|
||||||
const end = new Date(endDate);
|
const end = new Date(endDate);
|
||||||
let elapsedHours = 0;
|
let elapsedMinutes = 0;
|
||||||
|
|
||||||
// Move hour by hour and count only working hours
|
// Move minute by minute and count only working minutes
|
||||||
while (current < end) {
|
while (current < end) {
|
||||||
if (isWorkingTime(current)) {
|
if (isWorkingTime(current, priority)) {
|
||||||
elapsedHours++;
|
elapsedMinutes++;
|
||||||
}
|
}
|
||||||
current.setHours(current.getHours() + 1);
|
current.setMinutes(current.getMinutes() + 1);
|
||||||
|
|
||||||
// Safety: stop if calculating more than 1 year
|
// Safety: stop if calculating more than 1 year
|
||||||
if (elapsedHours > 8760) break;
|
const hoursSoFar = elapsedMinutes / 60;
|
||||||
|
if (hoursSoFar > 8760) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return elapsedHours;
|
// Convert minutes to hours (with decimal precision)
|
||||||
|
return elapsedMinutes / 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate remaining working hours to deadline
|
* Calculate remaining working hours to deadline
|
||||||
|
* @param deadline - Deadline date
|
||||||
|
* @param fromDate - Start date (defaults to now)
|
||||||
|
* @param priority - Priority type ('express' includes weekends, 'standard' excludes weekends)
|
||||||
*/
|
*/
|
||||||
export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date()): number {
|
export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date(), priority: string = 'standard'): number {
|
||||||
const deadlineTime = new Date(deadline).getTime();
|
const deadlineTime = new Date(deadline).getTime();
|
||||||
const currentTime = new Date(fromDate).getTime();
|
const currentTime = new Date(fromDate).getTime();
|
||||||
|
|
||||||
@ -131,15 +148,19 @@ export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate remaining working hours
|
// Calculate remaining working hours
|
||||||
return calculateElapsedWorkingHours(fromDate, deadline);
|
return calculateElapsedWorkingHours(fromDate, deadline, priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate SLA progress percentage
|
* Calculate SLA progress percentage
|
||||||
|
* @param startDate - Start date
|
||||||
|
* @param deadline - Deadline date
|
||||||
|
* @param currentDate - Current date (defaults to now)
|
||||||
|
* @param priority - Priority type ('express' includes weekends, 'standard' excludes weekends)
|
||||||
*/
|
*/
|
||||||
export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date()): number {
|
export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date(), priority: string = 'standard'): number {
|
||||||
const totalHours = calculateElapsedWorkingHours(startDate, deadline);
|
const totalHours = calculateElapsedWorkingHours(startDate, deadline, priority);
|
||||||
const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate);
|
const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate, priority);
|
||||||
|
|
||||||
if (totalHours === 0) return 0;
|
if (totalHours === 0) return 0;
|
||||||
|
|
||||||
@ -161,20 +182,22 @@ export interface SLAStatus {
|
|||||||
statusText: string;
|
statusText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSLAStatus(startDate: string | Date, deadline: string | Date): SLAStatus {
|
export function getSLAStatus(startDate: string | Date, deadline: string | Date, priority: string = 'standard'): SLAStatus {
|
||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
const end = new Date(deadline);
|
const end = new Date(deadline);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const isWorking = isWorkingTime(now);
|
const isWorking = isWorkingTime(now, priority);
|
||||||
const elapsedHours = calculateElapsedWorkingHours(start, now);
|
const elapsedHours = calculateElapsedWorkingHours(start, now, priority);
|
||||||
const totalHours = calculateElapsedWorkingHours(start, end);
|
const totalHours = calculateElapsedWorkingHours(start, end, priority);
|
||||||
const remainingHours = Math.max(0, totalHours - elapsedHours);
|
const remainingHours = Math.max(0, totalHours - elapsedHours);
|
||||||
const progress = calculateSLAProgress(start, end, now);
|
const progress = calculateSLAProgress(start, end, now, priority);
|
||||||
|
|
||||||
let statusText = '';
|
let statusText = '';
|
||||||
if (!isWorking) {
|
if (!isWorking) {
|
||||||
statusText = 'SLA tracking paused (outside working hours)';
|
statusText = priority === 'express'
|
||||||
|
? 'SLA tracking paused (outside working hours)'
|
||||||
|
: 'SLA tracking paused (outside working hours/days)';
|
||||||
} else if (remainingHours === 0) {
|
} else if (remainingHours === 0) {
|
||||||
statusText = 'SLA deadline reached';
|
statusText = 'SLA deadline reached';
|
||||||
} else if (progress >= 100) {
|
} else if (progress >= 100) {
|
||||||
@ -194,7 +217,7 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date):
|
|||||||
remainingHours,
|
remainingHours,
|
||||||
totalHours,
|
totalHours,
|
||||||
isPaused: !isWorking,
|
isPaused: !isWorking,
|
||||||
nextWorkingTime: !isWorking ? getNextWorkingTime(now) : undefined,
|
nextWorkingTime: !isWorking ? getNextWorkingTime(now, priority) : undefined,
|
||||||
statusText
|
statusText
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -204,29 +227,40 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date):
|
|||||||
*/
|
*/
|
||||||
export function formatWorkingHours(hours: number): string {
|
export function formatWorkingHours(hours: number): string {
|
||||||
if (hours === 0) return '0h';
|
if (hours === 0) return '0h';
|
||||||
|
if (hours < 0) return '0h';
|
||||||
|
|
||||||
const days = Math.floor(hours / 8); // 8 working hours per day
|
const totalMinutes = Math.round(hours * 60);
|
||||||
const remainingHours = hours % 8;
|
const days = Math.floor(totalMinutes / (8 * 60)); // 8 working hours per day
|
||||||
|
const remainingMinutes = totalMinutes % (8 * 60);
|
||||||
|
const remainingHours = Math.floor(remainingMinutes / 60);
|
||||||
|
const minutes = remainingMinutes % 60;
|
||||||
|
|
||||||
if (days > 0 && remainingHours > 0) {
|
if (days > 0 && remainingHours > 0 && minutes > 0) {
|
||||||
|
return `${days}d ${remainingHours}h ${minutes}m`;
|
||||||
|
} else if (days > 0 && remainingHours > 0) {
|
||||||
return `${days}d ${remainingHours}h`;
|
return `${days}d ${remainingHours}h`;
|
||||||
} else if (days > 0) {
|
} else if (days > 0) {
|
||||||
return `${days}d`;
|
return `${days}d`;
|
||||||
} else {
|
} else if (remainingHours > 0 && minutes > 0) {
|
||||||
|
return `${remainingHours}h ${minutes}m`;
|
||||||
|
} else if (remainingHours > 0) {
|
||||||
return `${remainingHours}h`;
|
return `${remainingHours}h`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}m`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get time until next working period
|
* Get time until next working period
|
||||||
|
* @param priority - Priority type ('express' includes weekends, 'standard' excludes weekends)
|
||||||
*/
|
*/
|
||||||
export function getTimeUntilNextWorking(): string {
|
export function getTimeUntilNextWorking(priority: string = 'standard'): string {
|
||||||
if (isWorkingTime()) {
|
if (isWorkingTime(new Date(), priority)) {
|
||||||
return 'In working hours';
|
return 'In working hours';
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const next = getNextWorkingTime(now);
|
const next = getNextWorkingTime(now, priority);
|
||||||
const diff = next.getTime() - now.getTime();
|
const diff = next.getTime() - now.getTime();
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user