From a030179d3c51b246b14c1ac1a1a72952c3ee944d Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Fri, 7 Nov 2025 19:39:12 +0530 Subject: [PATCH] redis server took from internal server and created dashboard and add spctctor and approver validated --- package-lock.json | 7 + package.json | 1 + .../AddApproverModal/AddApproverModal.tsx | 236 ++- .../AddSpectatorModal/AddSpectatorModal.tsx | 208 ++- src/pages/CreateRequest/CreateRequest.tsx | 606 ++++++-- src/pages/Dashboard/Dashboard.tsx | 1333 ++++++++++++++--- src/services/dashboard.service.ts | 305 ++++ 7 files changed, 2358 insertions(+), 338 deletions(-) create mode 100644 src/services/dashboard.service.ts diff --git a/package-lock.json b/package-lock.json index 57b0b82..5e59409 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", + "dayjs": "^1.11.19", "embla-carousel-react": "^8.3.0", "framer-motion": "^12.23.24", "input-otp": "^1.2.4", @@ -4060,6 +4061,12 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 3ad2dd4..5f46ca4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", + "dayjs": "^1.11.19", "embla-carousel-react": "^8.3.0", "framer-motion": "^12.23.24", "input-otp": "^1.2.4", diff --git a/src/components/participant/AddApproverModal/AddApproverModal.tsx b/src/components/participant/AddApproverModal/AddApproverModal.tsx index 5140318..780f777 100644 --- a/src/components/participant/AddApproverModal/AddApproverModal.tsx +++ b/src/components/participant/AddApproverModal/AddApproverModal.tsx @@ -1,13 +1,13 @@ import { useState, useRef, useEffect } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; -import { searchUsers, type UserSummary } from '@/services/userApi'; +import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle, Lightbulb } from 'lucide-react'; +import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi'; interface ApprovalLevelInfo { levelNumber: number; @@ -41,7 +41,21 @@ export function AddApproverModal({ const [isSubmitting, setIsSubmitting] = useState(false); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); // Track if user was selected via @ search const searchTimer = useRef(null); + + // Validation modal state + const [validationModal, setValidationModal] = useState<{ + open: boolean; + type: 'error' | 'not-found'; + email: string; + message: string; + }>({ + open: false, + type: 'error', + email: '', + message: '' + }); // Calculate available levels (after completed levels) const completedLevels = currentLevels.filter(l => @@ -64,36 +78,66 @@ export function AddApproverModal({ const emailToAdd = email.trim().toLowerCase(); if (!emailToAdd) { - alert('Please enter an email address'); + setValidationModal({ + open: true, + type: 'error', + email: '', + message: 'Please enter an email address' + }); return; } // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(emailToAdd)) { - alert('Please enter a valid email address'); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: 'Please enter a valid email address' + }); return; } // Validate TAT hours if (!tatHours || tatHours <= 0) { - alert('Please enter valid TAT hours (minimum 1 hour)'); + setValidationModal({ + open: true, + type: 'error', + email: '', + message: 'Please enter valid TAT hours (minimum 1 hour)' + }); return; } if (tatHours > 720) { - alert('TAT hours cannot exceed 720 hours (30 days)'); + setValidationModal({ + open: true, + type: 'error', + email: '', + message: 'TAT hours cannot exceed 720 hours (30 days)' + }); return; } // Validate level if (!selectedLevel) { - alert('Please select an approval level'); + setValidationModal({ + open: true, + type: 'error', + email: '', + message: 'Please select an approval level' + }); return; } if (selectedLevel < minLevel) { - alert(`Cannot add approver at level ${selectedLevel}. Minimum allowed level is ${minLevel} (after completed levels)`); + setValidationModal({ + open: true, + type: 'error', + email: '', + message: `Cannot add approver at level ${selectedLevel}. Minimum allowed level is ${minLevel} (after completed levels)` + }); return; } @@ -107,16 +151,76 @@ export function AddApproverModal({ const userName = existingParticipant.name || emailToAdd; if (participantType === 'INITIATOR') { - alert(`${userName} is the request initiator and cannot be added as an approver.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is the request initiator and cannot be added as an approver.` + }); return; } else if (participantType === 'APPROVER') { - alert(`${userName} is already an approver on this request.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is already an approver on this request.` + }); return; } else if (participantType === 'SPECTATOR') { - alert(`${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.` + }); return; } else { - alert(`${userName} is already a participant on this request.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is already a participant on this request.` + }); + return; + } + } + + // If user was NOT selected via @ search, validate against Okta + if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) { + try { + const searchOktaResults = await searchUsers(emailToAdd, 1); + + if (searchOktaResults.length === 0) { + // User not found in Okta + setValidationModal({ + open: true, + type: 'not-found', + email: emailToAdd, + message: '' + }); + return; + } + + // User found - ensure they exist in DB + const foundUser = searchOktaResults[0]; + await ensureUserExists({ + userId: foundUser.userId, + email: foundUser.email, + displayName: foundUser.displayName, + firstName: foundUser.firstName, + lastName: foundUser.lastName, + department: foundUser.department + }); + + console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`); + } catch (error) { + console.error('Failed to validate approver:', error); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: 'Failed to validate user. Please try again.' + }); return; } } @@ -127,6 +231,7 @@ export function AddApproverModal({ setEmail(''); setTatHours(24); setSelectedLevel(null); + setSelectedUser(null); onClose(); } catch (error) { console.error('Failed to add approver:', error); @@ -141,6 +246,7 @@ export function AddApproverModal({ setEmail(''); setTatHours(24); setSelectedLevel(null); + setSelectedUser(null); setSearchResults([]); setIsSearching(false); onClose(); @@ -170,6 +276,11 @@ export function AddApproverModal({ const handleEmailChange = (value: string) => { setEmail(value); + // Clear selectedUser when manually editing (forces revalidation) + if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) { + setSelectedUser(null); + } + // Clear existing timer if (searchTimer.current) { clearTimeout(searchTimer.current); @@ -199,10 +310,32 @@ export function AddApproverModal({ }; // Select user from search results - const handleSelectUser = (user: UserSummary) => { - setEmail(user.email); - setSearchResults([]); - setIsSearching(false); + const handleSelectUser = async (user: UserSummary) => { + // Ensure user exists in DB when selected via @ search + try { + await ensureUserExists({ + userId: user.userId, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + department: user.department + }); + + setEmail(user.email); + setSelectedUser(user); // Track that user was selected via @ search + setSearchResults([]); + setIsSearching(false); + console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`); + } catch (error) { + console.error('Failed to ensure user exists:', error); + setValidationModal({ + open: true, + type: 'error', + email: user.email, + message: 'Failed to verify user in database. Please try again.' + }); + } }; return ( @@ -419,6 +552,75 @@ export function AddApproverModal({ + + {/* Validation Error Modal */} + setValidationModal(prev => ({ ...prev, open: isOpen }))}> + + + + {validationModal.type === 'not-found' ? ( + <> + + User Not Found + + ) : ( + <> + + Validation Error + + )} + + +
+ {validationModal.type === 'not-found' && ( + <> +

+ User {validationModal.email} was not found in the organization directory. +

+
+

Please verify:

+
    +
  • Email address is spelled correctly
  • +
  • User exists in Okta/SSO system
  • +
  • User has an active account
  • +
+
+
+

+ + Tip: Use @ sign to search users from the directory. +

+
+ + )} + + {validationModal.type === 'error' && ( + <> + {validationModal.email && ( +

+ Failed to validate {validationModal.email}. +

+ )} + {validationModal.message && ( +
+

{validationModal.message}

+
+ )} + + )} +
+
+
+ + + +
+
); } diff --git a/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx b/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx index 78495e3..60ac142 100644 --- a/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx +++ b/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx @@ -1,10 +1,10 @@ import { useState, useRef, useEffect } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Eye, X, AtSign } from 'lucide-react'; -import { searchUsers, type UserSummary } from '@/services/userApi'; +import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react'; +import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi'; interface AddSpectatorModalProps { open: boolean; @@ -27,20 +27,44 @@ export function AddSpectatorModal({ const [isSubmitting, setIsSubmitting] = useState(false); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); // Track if user was selected via @ search const searchTimer = useRef(null); + + // Validation modal state + const [validationModal, setValidationModal] = useState<{ + open: boolean; + type: 'error' | 'not-found'; + email: string; + message: string; + }>({ + open: false, + type: 'error', + email: '', + message: '' + }); const handleConfirm = async () => { const emailToAdd = email.trim().toLowerCase(); if (!emailToAdd) { - alert('Please enter an email address'); + setValidationModal({ + open: true, + type: 'error', + email: '', + message: 'Please enter an email address' + }); return; } // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(emailToAdd)) { - alert('Please enter a valid email address'); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: 'Please enter a valid email address' + }); return; } @@ -54,16 +78,76 @@ export function AddSpectatorModal({ const userName = existingParticipant.name || emailToAdd; if (participantType === 'INITIATOR') { - alert(`${userName} is the request initiator and cannot be added as a spectator.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is the request initiator and cannot be added as a spectator.` + }); return; } else if (participantType === 'APPROVER') { - alert(`${userName} is already an approver on this request and cannot be added as a spectator.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is already an approver on this request and cannot be added as a spectator.` + }); return; } else if (participantType === 'SPECTATOR') { - alert(`${userName} is already a spectator on this request.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is already a spectator on this request.` + }); return; } else { - alert(`${userName} is already a participant on this request.`); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: `${userName} is already a participant on this request.` + }); + return; + } + } + + // If user was NOT selected via @ search, validate against Okta + if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) { + try { + const searchOktaResults = await searchUsers(emailToAdd, 1); + + if (searchOktaResults.length === 0) { + // User not found in Okta + setValidationModal({ + open: true, + type: 'not-found', + email: emailToAdd, + message: '' + }); + return; + } + + // User found - ensure they exist in DB + const foundUser = searchOktaResults[0]; + await ensureUserExists({ + userId: foundUser.userId, + email: foundUser.email, + displayName: foundUser.displayName, + firstName: foundUser.firstName, + lastName: foundUser.lastName, + department: foundUser.department + }); + + console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`); + } catch (error) { + console.error('Failed to validate spectator:', error); + setValidationModal({ + open: true, + type: 'error', + email: emailToAdd, + message: 'Failed to validate user. Please try again.' + }); return; } } @@ -72,6 +156,7 @@ export function AddSpectatorModal({ setIsSubmitting(true); await onConfirm(emailToAdd); setEmail(''); + setSelectedUser(null); onClose(); } catch (error) { console.error('Failed to add spectator:', error); @@ -84,6 +169,7 @@ export function AddSpectatorModal({ const handleClose = () => { if (!isSubmitting) { setEmail(''); + setSelectedUser(null); setSearchResults([]); setIsSearching(false); onClose(); @@ -103,6 +189,11 @@ export function AddSpectatorModal({ const handleEmailChange = (value: string) => { setEmail(value); + // Clear selectedUser when manually editing (forces revalidation) + if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) { + setSelectedUser(null); + } + // Clear existing timer if (searchTimer.current) { clearTimeout(searchTimer.current); @@ -132,10 +223,32 @@ export function AddSpectatorModal({ }; // Select user from search results - const handleSelectUser = (user: UserSummary) => { - setEmail(user.email); - setSearchResults([]); - setIsSearching(false); + const handleSelectUser = async (user: UserSummary) => { + // Ensure user exists in DB when selected via @ search + try { + await ensureUserExists({ + userId: user.userId, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + department: user.department + }); + + setEmail(user.email); + setSelectedUser(user); // Track that user was selected via @ search + setSearchResults([]); + setIsSearching(false); + console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`); + } catch (error) { + console.error('Failed to ensure user exists:', error); + setValidationModal({ + open: true, + type: 'error', + email: user.email, + message: 'Failed to verify user in database. Please try again.' + }); + } }; return ( @@ -249,6 +362,75 @@ export function AddSpectatorModal({ + + {/* Validation Error Modal */} + setValidationModal(prev => ({ ...prev, open: isOpen }))}> + + + + {validationModal.type === 'not-found' ? ( + <> + + User Not Found + + ) : ( + <> + + Validation Error + + )} + + +
+ {validationModal.type === 'not-found' && ( + <> +

+ User {validationModal.email} was not found in the organization directory. +

+
+

Please verify:

+
    +
  • Email address is spelled correctly
  • +
  • User exists in Okta/SSO system
  • +
  • User has an active account
  • +
+
+
+

+ + Tip: Use @ sign to search users from the directory. +

+
+ + )} + + {validationModal.type === 'error' && ( + <> + {validationModal.email && ( +

+ Failed to validate {validationModal.email}. +

+ )} + {validationModal.message && ( +
+

{validationModal.message}

+
+ )} + + )} +
+
+
+ + + +
+
); } diff --git a/src/pages/CreateRequest/CreateRequest.tsx b/src/pages/CreateRequest/CreateRequest.tsx index ad617e1..0c41693 100644 --- a/src/pages/CreateRequest/CreateRequest.tsx +++ b/src/pages/CreateRequest/CreateRequest.tsx @@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { motion, AnimatePresence } from 'framer-motion'; import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal'; import { @@ -221,6 +222,19 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd const [loadingDraft, setLoadingDraft] = useState(isEditing); const [existingDocuments, setExistingDocuments] = useState([]); // Track documents from backend const [documentsToDelete, setDocumentsToDelete] = useState([]); // Track document IDs to delete + + // Validation modal states + const [validationModal, setValidationModal] = useState<{ + open: boolean; + type: 'error' | 'self-assign' | 'not-found'; + email: string; + message: string; + }>({ + open: false, + type: 'error', + email: '', + message: '' + }); // Fetch draft data when in edit mode useEffect(() => { @@ -392,6 +406,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd formData.approvers.every(approver => { if (!approver || !approver.email) return false; + // Check if email is valid + if (!validateEmail(approver.email)) return false; + + // Check if approver has a userId (selected via @ search) + // If no userId, it means they manually typed the email + if (!approver.userId) { + // Will be validated and ensured when moving to next step + return true; // Allow for now, will validate on next step + } + // Check TAT validation based on type const tatType = approver.tatType || 'hours'; if (tatType === 'hours') { @@ -408,8 +432,122 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd } }; - const nextStep = () => { - if (currentStep < totalSteps && isStepValid()) { + const nextStep = async () => { + if (!isStepValid()) return; + + // Scroll to top on mobile to ensure buttons are visible + if (window.innerWidth < 640) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + // Special validation when leaving step 3 (Approval Workflow) + if (currentStep === 3) { + // Validate and ensure all approvers with manually entered emails exist + const approversToValidate = formData.approvers.filter(a => a && a.email && !a.userId); + + if (approversToValidate.length > 0) { + try { + // Show loading state (optional - can be added later) + const updatedApprovers = [...formData.approvers]; + + for (let i = 0; i < updatedApprovers.length; i++) { + const approver = updatedApprovers[i]; + + // Skip if already has userId (selected via @ search) + if (approver.userId) continue; + + // Skip if no email + if (!approver.email) continue; + + // Check if this email is the initiator's email + const initiatorEmail = (user as any)?.email?.toLowerCase(); + if (approver.email.toLowerCase() === initiatorEmail) { + setValidationModal({ + open: true, + type: 'self-assign', + email: approver.email, + message: '' + }); + return; // Stop navigation + } + + // Search for the user by email in Okta directory + try { + const searchResults = await searchUsers(approver.email, 1); + + if (searchResults.length === 0) { + // User NOT found in Okta directory + setValidationModal({ + open: true, + type: 'not-found', + email: approver.email, + message: '' + }); + return; // Stop navigation - user must fix the email + } else { + // User found in Okta - ensure they exist in our DB and get userId + const foundUser = searchResults[0]; + + if (!foundUser) { + setValidationModal({ + open: true, + type: 'error', + email: approver.email, + message: 'Could not retrieve user details. Please try again.' + }); + return; + } + + // Ensure user exists in our database (creates if needed) + const dbUser = await ensureUserExists({ + userId: foundUser.userId, + email: foundUser.email, + displayName: foundUser.displayName, + firstName: foundUser.firstName, + lastName: foundUser.lastName, + department: foundUser.department + }); + + // Update approver with DB userId and full details + updatedApprovers[i] = { + ...approver, + userId: dbUser.userId, + name: dbUser.displayName || approver.name, + department: dbUser.department || approver.department, + avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase() + }; + + console.log(`✅ Validated approver ${i + 1}: ${dbUser.displayName} (${dbUser.email})`); + } + } catch (error) { + console.error(`Failed to validate approver ${approver.email}:`, error); + setValidationModal({ + open: true, + type: 'error', + email: approver.email, + message: `Failed to validate user. Please try again or select a different user.` + }); + return; // Stop navigation + } + } + + // Update form data with validated approvers + updateFormData('approvers', updatedApprovers); + } catch (error) { + console.error('Failed to validate approvers:', error); + setValidationModal({ + open: true, + type: 'error', + email: '', + message: 'Failed to validate approvers. Please try again.' + }); + return; // Stop navigation + } + } + } + + // Proceed to next step + if (currentStep < totalSteps) { setCurrentStep(currentStep + 1); } }; @@ -417,6 +555,10 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd const prevStep = () => { if (currentStep > 1) { setCurrentStep(currentStep - 1); + // Scroll to top on mobile to ensure content is visible + if (window.innerWidth < 640) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } } }; @@ -450,7 +592,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd // Check if user is already in the target list if (currentList.find((u: any) => u.id === user.id || (u.email || '').toLowerCase() === userEmail)) { - alert(`${user.name || user.email} is already added as ${type.slice(0, -1)}.`); + setValidationModal({ + open: true, + type: 'error', + email: user.email, + message: `${user.name || user.email} is already added as ${type.slice(0, -1)}.` + }); return; } @@ -461,7 +608,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd a.id === user.id || (a.email || '').toLowerCase() === userEmail ); if (isApprover) { - alert(`${user.name || user.email} is already an approver and cannot be added as a spectator.`); + setValidationModal({ + open: true, + type: 'error', + email: user.email, + message: `${user.name || user.email} is already an approver and cannot be added as a spectator.` + }); return; } } else if (type === 'approvers') { @@ -470,7 +622,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd s.id === user.id || (s.email || '').toLowerCase() === userEmail ); if (isSpectator) { - alert(`${user.name || user.email} is already a spectator and cannot be added as an approver.`); + setValidationModal({ + open: true, + type: 'error', + email: user.email, + message: `${user.name || user.email} is already a spectator and cannot be added as an approver.` + }); return; } } @@ -524,10 +681,91 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd return newUser; }; - const inviteAndAddUser = (type: 'approvers' | 'spectators' | 'ccList') => { - const user = addUserByEmail(); - if (user) { - addUser(user, type); + const inviteAndAddUser = async (type: 'approvers' | 'spectators' | 'ccList') => { + // For spectators, validate against Okta before adding + if (type === 'spectators' && emailInput) { + // Check if this email is the initiator's email + const initiatorEmail = (user as any)?.email?.toLowerCase(); + if (emailInput.toLowerCase() === initiatorEmail) { + setValidationModal({ + open: true, + type: 'self-assign', + email: emailInput, + message: 'You cannot add yourself as a spectator.' + }); + return; + } + + // Search for user in Okta directory + try { + const searchResults = await searchUsers(emailInput, 1); + + if (searchResults.length === 0) { + // User NOT found in Okta directory + setValidationModal({ + open: true, + type: 'not-found', + email: emailInput, + message: '' + }); + return; + } + + // User found in Okta - ensure they exist in DB + const foundUser = searchResults[0]; + + if (!foundUser) { + setValidationModal({ + open: true, + type: 'error', + email: emailInput, + message: 'Could not retrieve user details. Please try again.' + }); + return; + } + + const dbUser = await ensureUserExists({ + userId: foundUser.userId, + email: foundUser.email, + displayName: foundUser.displayName, + firstName: foundUser.firstName, + lastName: foundUser.lastName, + department: foundUser.department + }); + + // Create spectator object with verified data + const spectator = { + id: dbUser.userId, + userId: dbUser.userId, + name: dbUser.displayName || dbUser.email.split('@')[0], + email: dbUser.email, + avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase(), + role: 'Spectator', + department: dbUser.department || '', + level: 1, + canClose: false + }; + + // Add spectator + addUser(spectator, 'spectators'); + setEmailInput(''); + return; + } catch (error) { + console.error('Failed to validate spectator:', error); + setValidationModal({ + open: true, + type: 'error', + email: emailInput, + message: 'Failed to validate user. Please try again.' + }); + return; + } + } + + // For other types (deprecated flow) + const userObj = addUserByEmail(); + if (userObj) { + addUser(userObj, type); } }; @@ -1402,9 +1640,23 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
- +
+ + {formData.approvers[index]?.email && formData.approvers[index]?.userId && ( + + + Verified + + )} + {formData.approvers[index]?.email && !formData.approvers[index]?.userId && ( + + + Needs Validation + + )} +
{ const value = e.target.value; const newApprovers = [...formData.approvers]; + + // If email changed, clear userId to force revalidation + const previousEmail = newApprovers[index]?.email; + const emailChanged = previousEmail !== value; + newApprovers[index] = { ...newApprovers[index], email: value, - level: level + level: level, + // Clear userId if email was changed (requires revalidation) + userId: emailChanged ? undefined : newApprovers[index]?.userId, + name: emailChanged ? undefined : newApprovers[index]?.name, + department: emailChanged ? undefined : newApprovers[index]?.department, + avatar: emailChanged ? undefined : newApprovers[index]?.avatar }; updateFormData('approvers', newApprovers); @@ -1462,7 +1724,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd const spectatorIds = (formData.spectators || []).map((s: any) => s?.id).filter(Boolean); const spectatorEmails = (formData.spectators || []).map((s: any) => s?.email?.toLowerCase?.()).filter(Boolean); if (spectatorIds.includes(u.userId) || spectatorEmails.includes((u.email || '').toLowerCase())) { - alert(`${u.displayName || u.email} is already a spectator and cannot be added as an approver.`); + setValidationModal({ + open: true, + type: 'error', + email: u.email, + message: `${u.displayName || u.email} is already a spectator and cannot be added as an approver.` + }); return; } @@ -1472,7 +1739,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd .map((a: any) => a?.email?.toLowerCase?.()) .filter(Boolean); if (approverEmails.includes((u.email || '').toLowerCase())) { - alert(`${u.displayName || u.email} is already an approver at another level.`); + setValidationModal({ + open: true, + type: 'error', + email: u.email, + message: `${u.displayName || u.email} is already an approver at another level.` + }); return; } @@ -1515,10 +1787,27 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
)}
-

- @ - Use @ sign to tag a user -

+ {formData.approvers[index]?.userId ? ( +

+ + User verified in organization directory +

+ ) : formData.approvers[index]?.email ? ( +
+

+ + This email will be validated against Okta directory +

+

+ If user doesn't exist in Okta, you won't be able to proceed. Use @ search for guaranteed results. +

+
+ ) : ( +

+ @ + Use @ sign to search and select a user (recommended) +

+ )}
{/* Peer Approver Section */} @@ -1816,9 +2105,11 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd } }, 300); }} - onKeyPress={(e) => { + onKeyPress={async (e) => { + // Allow Enter key to add spectator (will validate) if (e.key === 'Enter' && validateEmail(emailInput)) { - inviteAndAddUser('spectators'); + e.preventDefault(); + await inviteAndAddUser('spectators'); } }} className="text-sm w-full" @@ -1838,7 +2129,12 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean); const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean); if (approverIds.includes(u.userId) || approverEmails.includes((u.email || '').toLowerCase())) { - alert(`${u.displayName || u.email} is already an approver and cannot be added as a spectator.`); + setValidationModal({ + open: true, + type: 'error', + email: u.email, + message: `${u.displayName || u.email} is already an approver and cannot be added as a spectator.` + }); setEmailInput(''); setSpectatorSearchResults([]); return; @@ -1889,12 +2185,20 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd +

+ + + Use @ sign to search users, or type email directly (will be validated against organization directory) + +

@@ -2501,23 +2805,23 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd return (
- {/* Header */} -
-
-
- -
-

- {isEditing ? 'Edit Draft Request' : 'Create New Request'} +
+

+ {isEditing ? 'Edit Draft' : 'New Request'}

-

+

Step {currentStep} of {totalSteps}: {STEP_NAMES[currentStep - 1]}

-
+

{Math.round((currentStep / totalSteps) * 100)}% Complete

{totalSteps - currentStep} steps remaining

@@ -2526,96 +2830,134 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
- {/* Progress Bar */} -
+ {/* Progress Bar - Mobile Optimized */} +
-
- {STEP_NAMES.map((_, index) => ( -
-
- {index + 1 < currentStep ? ( - - ) : ( - index + 1 + {/* Mobile: Current step indicator only */} +
+
+
+
+ {currentStep} +
+
+

{STEP_NAMES[currentStep - 1]}

+

Step {currentStep} of {totalSteps}

+
+
+
+

{Math.round((currentStep / totalSteps) * 100)}%

+
+
+ {/* Progress bar */} +
+
+
+
+ + {/* Desktop: Full step indicator */} +
+
+ {STEP_NAMES.map((_, index) => ( +
+
+ {index + 1 < currentStep ? ( + + ) : ( + index + 1 + )} +
+ {index < STEP_NAMES.length - 1 && ( +
)}
- {index < STEP_NAMES.length - 1 && ( -
- )} -
- ))} -
-
- {STEP_NAMES.map((step, index) => ( - - {step} - - ))} + ))} +
+
+ {STEP_NAMES.map((step, index) => ( + + {step} + + ))} +
- {/* Content */} -
-
+ {/* Content - with extra bottom padding for mobile keyboards */} +
+
{renderStepContent()}
- {/* Footer */} -
-
+ {/* Footer - Fixed on mobile for better keyboard handling */} +
+
+ {/* Previous Button */} -
- {currentStep === totalSteps ? ( ) : ( )}
@@ -2628,6 +2970,96 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd onClose={() => setShowTemplateModal(false)} onSelectTemplate={handleTemplateSelection} /> + + {/* Validation Error Modal */} + setValidationModal(prev => ({ ...prev, open }))}> + + + + {validationModal.type === 'self-assign' && ( + <> + + Cannot Add Yourself + + )} + {validationModal.type === 'not-found' && ( + <> + + User Not Found + + )} + {validationModal.type === 'error' && ( + <> + + Validation Error + + )} + + +
+ {validationModal.type === 'self-assign' && ( + <> +

+ You cannot add yourself ({validationModal.email}) as an approver. +

+
+

+ Why? The initiator creates the request and cannot approve their own request. Please select a different user. +

+
+ + )} + + {validationModal.type === 'not-found' && ( + <> +

+ User {validationModal.email} was not found in the organization directory. +

+
+

Please verify:

+
    +
  • Email address is spelled correctly
  • +
  • User exists in Okta/SSO system
  • +
  • User has an active account
  • +
+
+
+

+ + Tip: Use @ sign to search and select users from the directory for guaranteed results. +

+
+ + )} + + {validationModal.type === 'error' && ( + <> +

+ {validationModal.email && ( + <>Failed to validate {validationModal.email}. + )} + {!validationModal.email && <>An error occurred during validation.} +

+ {validationModal.message && ( +
+

{validationModal.message}

+
+ )} + + )} +
+
+
+ + + +
+
); } \ No newline at end of file diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx index 0641ec3..26b7323 100644 --- a/src/pages/Dashboard/Dashboard.tsx +++ b/src/pages/Dashboard/Dashboard.tsx @@ -1,144 +1,49 @@ -import { useMemo } from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; import { FileText, Clock, AlertTriangle, - TrendingUp, CheckCircle, Zap, Shield, ArrowRight, - Bell, Star, Activity, - Calendar, Target, Flame, - Settings + Settings, + RefreshCw, + MessageSquare, + Paperclip, + Filter, + Download, + Users, + PieChart, + Calendar } from 'lucide-react'; +import { dashboardService, type DashboardKPIs, type RecentActivity, type CriticalRequest, type DateRange } from '@/services/dashboard.service'; +import { differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'; +import { useAuth } from '@/contexts/AuthContext'; interface DashboardProps { onNavigate?: (page: string) => void; onNewRequest?: () => void; } -// Static data to prevent re-creation on each render -const STATS_DATA = [ - { - title: 'Open Requests', - value: '24', - description: '+3 from last week', - icon: FileText, - trend: 'up', - color: 'text-emerald-600', - bgColor: 'bg-emerald-50', - change: '+12.5%' - }, - { - title: 'Avg. SLA Compliance', - value: '87%', - description: '+5% from last month', - icon: Target, - trend: 'up', - color: 'text-blue-600', - bgColor: 'bg-blue-50', - change: '+5.8%' - }, - { - title: 'High Priority Alerts', - value: '5', - description: 'Requires immediate attention', - icon: Flame, - trend: 'neutral', - color: 'text-red-600', - bgColor: 'bg-red-50', - change: '-2' - }, - { - title: 'Approved This Month', - value: '142', - description: '+12% from last month', - icon: CheckCircle, - trend: 'up', - color: 'text-green-600', - bgColor: 'bg-green-50', - change: '+18.2%' - } -]; - -const RECENT_ACTIVITY = [ - { - id: 'RE-REQ-024', - title: 'Marketing Campaign Approval', - user: 'Sarah Chen', - action: 'approved', - time: '2 minutes ago', - avatar: 'SC', - priority: 'high' - }, - { - id: 'RE-REQ-023', - title: 'Budget Allocation Request', - user: 'Mike Johnson', - action: 'commented', - time: '15 minutes ago', - avatar: 'MJ', - priority: 'medium' - }, - { - id: 'RE-REQ-022', - title: 'Vendor Contract Review', - user: 'David Kumar', - action: 'submitted', - time: '1 hour ago', - avatar: 'DK', - priority: 'low' - }, - { - id: 'RE-REQ-021', - title: 'IT Equipment Purchase', - user: 'Lisa Wong', - action: 'escalated', - time: '2 hours ago', - avatar: 'LW', - priority: 'high' - }, - { - id: 'RE-REQ-020', - title: 'Office Space Lease', - user: 'John Doe', - action: 'rejected', - time: '3 hours ago', - avatar: 'JD', - priority: 'medium' - } -]; - -const HIGH_PRIORITY_REQUESTS = [ - { id: 'RE-REQ-001', title: 'Emergency Equipment Purchase', sla: '2 hours left', progress: 85 }, - { id: 'RE-REQ-005', title: 'Critical Vendor Agreement', sla: '4 hours left', progress: 60 }, - { id: 'RE-REQ-012', title: 'Urgent Marketing Approval', sla: '6 hours left', progress: 40 } -]; // Utility functions outside component -const getActionColor = (action: string) => { - switch (action) { - case 'approved': return 'text-emerald-700 bg-emerald-100 border-emerald-200'; - case 'rejected': return 'text-red-700 bg-red-100 border-red-200'; - case 'commented': return 'text-blue-700 bg-blue-100 border-blue-200'; - case 'escalated': return 'text-orange-700 bg-orange-100 border-orange-200'; - case 'submitted': return 'text-purple-700 bg-purple-100 border-purple-200'; - default: return 'text-gray-700 bg-gray-100 border-gray-200'; - } -}; - const getPriorityColor = (priority: string) => { - switch (priority) { + const p = priority.toLowerCase(); + switch (p) { + case 'express': return 'bg-orange-100 text-orange-800 border-orange-200'; + case 'standard': return 'bg-blue-100 text-blue-800 border-blue-200'; case 'high': return 'bg-red-100 text-red-800 border-red-200'; case 'medium': return 'bg-orange-100 text-orange-800 border-orange-200'; case 'low': return 'bg-green-100 text-green-800 border-green-200'; @@ -147,6 +52,64 @@ const getPriorityColor = (priority: string) => { }; export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { + const { user } = useAuth(); + const [kpis, setKpis] = useState(null); + const [recentActivity, setRecentActivity] = useState([]); + const [criticalRequests, setCriticalRequests] = useState([]); + const [departmentStats, setDepartmentStats] = useState([]); + const [priorityDistribution, setPriorityDistribution] = useState([]); + const [upcomingDeadlines, setUpcomingDeadlines] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Filter states + const [dateRange, setDateRange] = useState('month'); + + // Determine user role + const isAdmin = useMemo(() => { + return (user as any)?.isAdmin || false; + }, [user]); + + // Fetch dashboard data + const fetchDashboardData = useCallback(async (showRefreshing = false, selectedDateRange: DateRange = 'month') => { + try { + if (showRefreshing) setRefreshing(true); + else setLoading(true); + + const [ + kpisData, + activityData, + criticalData, + deptStats, + priorityDist, + deadlines + ] = await Promise.all([ + dashboardService.getKPIs(selectedDateRange), + dashboardService.getRecentActivity(10), + dashboardService.getCriticalRequests(), + dashboardService.getDepartmentStats(selectedDateRange), + dashboardService.getPriorityDistribution(selectedDateRange), + dashboardService.getUpcomingDeadlines(10) + ]); + + setKpis(kpisData); + setRecentActivity(activityData); + setCriticalRequests(criticalData); + setDepartmentStats(deptStats); + setPriorityDistribution(priorityDist); + setUpcomingDeadlines(deadlines); + } catch (error) { + console.error('Failed to fetch dashboard data:', error); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + fetchDashboardData(false, dateRange); + }, [fetchDashboardData, dateRange]); + // Memoize quick actions to prevent recreation on each render const quickActions = useMemo(() => [ { label: 'New Request', icon: FileText, action: () => onNewRequest?.(), color: 'bg-emerald-600 hover:bg-emerald-700' }, @@ -155,34 +118,91 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { { label: 'Settings', icon: Settings, action: () => {}, color: 'bg-slate-600 hover:bg-slate-700' } ], [onNavigate, onNewRequest]); + // Format relative time + const getRelativeTime = (timestamp: string) => { + const now = new Date(); + const time = new Date(timestamp); + const diffMin = differenceInMinutes(now, time); + + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`; + + const diffHrs = differenceInHours(now, time); + if (diffHrs < 24) return `${diffHrs} hour${diffHrs > 1 ? 's' : ''} ago`; + + const diffDay = differenceInDays(now, time); + return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`; + }; + + // Calculate progress for critical requests + const calculateProgress = (request: CriticalRequest) => { + if (!request.currentLevel || !request.totalLevels) return 0; + return Math.round((request.currentLevel / request.totalLevels) * 100); + }; + + // Format remaining time (can be negative if breached) + const formatRemainingTime = (request: CriticalRequest) => { + if (request.totalTATHours === undefined || request.totalTATHours === null) return 'N/A'; + + const hours = request.totalTATHours; + + // If TAT is breached (negative or zero) + if (hours <= 0) { + const overdue = Math.abs(hours); + if (overdue < 1) return `Breached`; + if (overdue < 24) return `${Math.round(overdue)}h overdue`; + return `${Math.round(overdue / 24)}d overdue`; + } + + // If TAT is still remaining + if (hours < 1) return `${Math.round(hours * 60)}min left`; + if (hours < 24) return `${Math.round(hours)}h left`; + return `${Math.round(hours / 24)}d left`; + }; + + if (loading) { + return ( +
+
+ +

Loading dashboard...

+
+
+ ); + } + return ( -
+
{/* Hero Section with Clear Background */}
- -
-
-
-
- + +
+
+
+
+
-

Welcome to Royal Enfield Portal

-

Rev up your workflow with streamlined approvals

+

+ {isAdmin ? 'Admin Dashboard' : 'My Dashboard'} +

+

+ {isAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'} +

-
+
{quickActions.map((action, index) => ( ))} @@ -204,167 +224,1038 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { - {/* Stats Cards with Better Contrast */} -
- {STATS_DATA.map((stat, index) => ( - - + {/* Filters Bar */} + + +
+
+ +

Filters

+ {isAdmin && ( + + Admin View + + )} +
+ +
+ {/* Date Range Filter */} +
+ + +
+ + + + {/* Refresh Button */} + + + {/* Export Button */} + {isAdmin && ( + + )} +
+
+
+
+ + {/* ADMIN DASHBOARD - Comprehensive View */} + {isAdmin ? ( + <> + {/* Admin Stats Cards */} +
+ {/* Total Requests */} + + + + Total Requests + +
+ +
+
+ +
+ {kpis?.requestVolume.totalRequests || 0} +
+
+
+

Approved

+

{kpis?.requestVolume.approvedRequests || 0}

+
+
+

Rejected

+

{kpis?.requestVolume.rejectedRequests || 0}

+
+
+
+
+ + {/* Open Requests */} + + + + Open Requests + +
+ +
+
+ +
+ {kpis?.requestVolume.openRequests || 0} +
+
+
+

Pending

+

+ {kpis ? kpis.requestVolume.openRequests - criticalRequests.length : 0} +

+
+
+

Critical

+

{criticalRequests.length}

+
+
+
+
+ + {/* SLA Compliance */} + + + + SLA Compliance + +
+ +
+
+ +
+ {kpis?.tatEfficiency.avgTATCompliance || 0}% +
+ +
+
+

Compliant

+

{kpis?.tatEfficiency.compliantWorkflows || 0}

+
+
+

Breached

+

{kpis?.tatEfficiency.delayedWorkflows || 0}

+
+
+
+
+ + {/* Avg Cycle Time */} + + + + Avg Cycle Time + +
+ +
+
+ +
+ + {kpis?.tatEfficiency.avgCycleTimeHours.toFixed(1) || 0} + + hours +
+
+ ≈ {kpis?.tatEfficiency.avgCycleTimeDays.toFixed(1) || 0} working days +
+
+
+

Express

+

+ {(() => { + const express = priorityDistribution.find(p => p.priority === 'express'); + const hours = express ? Number(express.avgCycleTimeHours) : 0; + return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; + })()} +

+
+
+

Standard

+

+ {(() => { + const standard = priorityDistribution.find(p => p.priority === 'standard'); + const hours = standard ? Number(standard.avgCycleTimeHours) : 0; + return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; + })()} +

+
+
+
+
+
+ + ) : ( + <> + {/* NORMAL USER DASHBOARD - Personal View */} +
+ {/* My Requests Created */} + + + + My Requests (Submitted) + +
+ +
+
+ +
+ {kpis?.requestVolume.totalRequests || 0} +
+
+
+

Approved

+

{kpis?.requestVolume.approvedRequests || 0}

+
+
+

Pending

+

{kpis?.requestVolume.openRequests || 0}

+
+
+

Draft

+

{kpis?.requestVolume.draftRequests || 0}

+
+
+
+
+ + {/* My Pending Actions (Current Approver) */} + + + + Awaiting My Approval + +
+ +
+
+ +
+ {kpis?.approverLoad.pendingActions || 0} +
+
+ at current level +
+
+
+

Approved Today

+

{kpis?.approverLoad.completedToday || 0}

+
+
+

This Week

+

{kpis?.approverLoad.completedThisWeek || 0}

+
+
+
+
+ + {/* Critical Alerts */} + + - {stat.title} + Critical Alerts -
- +
+
-
-
-
{stat.value}
-
- {stat.trend === 'up' && ( -
- - {stat.change} +
+ {criticalRequests.length} +
+
+
+

Breached

+

+ {criticalRequests.filter(r => r.breachCount > 0).length} +

- )} +
+

Warning

+

+ {criticalRequests.filter(r => r.breachCount === 0).length} +

- + + + + {/* My Success Rate */} + + + + Success Rate + +
+ +
+
+ +
+ {kpis && kpis.requestVolume.totalRequests > 0 + ? ((kpis.requestVolume.approvedRequests / kpis.requestVolume.totalRequests) * 100).toFixed(0) + : 0}% +
+
+ of {kpis?.requestVolume.totalRequests || 0} requests approved +
+
+ Rejected + {kpis?.requestVolume.rejectedRequests || 0}
-

{stat.description}

- ))}
+ + )} -
+
{/* High Priority Alerts */} - - + +
-
-
- +
+
+
- Critical Alerts - Urgent attention required + Critical Alerts + + {isAdmin ? 'Organization-wide' : 'My requests'} +
- - {HIGH_PRIORITY_REQUESTS.length} + 0 ? 'animate-pulse' : ''}`}> + {criticalRequests.length}
- - {HIGH_PRIORITY_REQUESTS.map((request) => ( -
-
+ + {criticalRequests.length === 0 ? ( +
+ +

No critical alerts

+

All requests are within TAT

+
+ ) : ( + criticalRequests.slice(0, 3).map((request) => ( +
onNavigate?.(`request/${request.requestNumber}`)}> +
-
-

{request.id}

- +
+

{request.requestNumber}

+ {request.priority === 'express' && } + {request.breachCount > 0 && ( + + {request.breachCount} + + )} +
+

{request.title}

-

{request.title}

+ + {formatRemainingTime(request)} +
- - {request.sla} - -
-
+
Progress - {request.progress}% + {calculateProgress(request)}%
- ))} + )) + )} {/* Recent Activity */} - - -
-
-
- + + +
+
+
+
-
- Recent Activity - Latest updates across all requests +
+ Recent Activity + + {isAdmin ? 'All workflow updates' : 'My workflow updates'} +
-
-
- {RECENT_ACTIVITY.map((activity) => ( -
-
- +
+ {recentActivity.length === 0 ? ( +
+ +

No recent activity

+

Activity will appear here once requests are processed

+
+ ) : ( + recentActivity.map((activity) => { + // Check if this activity is by the current user + const currentUserId = (user as any)?.userId; + const isCurrentUser = activity.userId === currentUserId; + const displayName = isCurrentUser ? 'You' : (activity.userName || 'System'); + + const userInitials = isCurrentUser + ? ((user as any)?.displayName || (user as any)?.email || 'ME') + .split(' ') + .map((n: string) => n[0]) + .join('') + .toUpperCase() + .substring(0, 2) + : activity.userName + ? activity.userName + .split(' ') + .map(n => n[0]) + .join('') + .toUpperCase() + .substring(0, 2) + : 'SY'; // System default + + // Clean up activity description - remove email addresses and make concise + const cleanActivityDescription = (desc: string) => { + if (!desc) return desc; + + // Remove email addresses in parentheses + let cleaned = desc.replace(/\s*\([^)]*@[^)]*\)/g, ''); + + // Remove "by [user]" at the end - we show user separately + cleaned = cleaned.replace(/\s+by\s+.+$/i, ''); + + // Shorten common phrases + cleaned = cleaned.replace(/has been added as approver/gi, 'added as approver'); + cleaned = cleaned.replace(/has been added as spectator/gi, 'added as spectator'); + cleaned = cleaned.replace(/has been/gi, ''); + + // Make TAT format more compact + cleaned = cleaned.replace(/with TAT of (\d+) hours?/gi, '(TAT: $1h)'); + cleaned = cleaned.replace(/with TAT of (\d+) days?/gi, '(TAT: $1d)'); + + // Replace multiple spaces with single space + cleaned = cleaned.replace(/\s+/g, ' '); + + return cleaned.trim(); + }; + + // Get action icon + const getActionIcon = () => { + const action = activity.action.toLowerCase(); + if (action.includes('approv')) return ; + if (action.includes('reject')) return ; + if (action.includes('comment')) return ; + if (action.includes('escalat')) return ; + if (action.includes('submit')) return ; + if (action.includes('document')) return ; + return ; + }; + + return ( +
onNavigate?.(`request/${activity.requestNumber}`)} + > +
+ - - {activity.avatar} + + {userInitials} -
- {activity.action === 'approved' && } - {activity.action === 'rejected' && } - {activity.action === 'commented' && } - {activity.action === 'escalated' && } - {activity.action === 'submitted' && } +
+ {getActionIcon()}
-
- {activity.id} + {/* Header with Request Number and Priority Badge */} +
+
+ + {activity.requestNumber} + - {activity.action} - - {activity.priority}
-

{activity.title}

-

- by {activity.user} • {activity.time} -

-
- - -
- ))} +
+ + {/* Action Description as Text */} +

+ + {cleanActivityDescription(activity.action)} + +

+ + {/* Request Title */} +

+ {activity.requestTitle} +

+ + {/* User and Time */} +
+ + {displayName} + + + {getRelativeTime(activity.timestamp)} +
+
+ + +
+ ); + }) + )}
+ + {/* ADMIN - Additional Analytics */} + {isAdmin && kpis && ( +
+ {/* Active Levels Tracking */} + + +
+
+ +
+ Active Levels +
+
+ +
+
+ {upcomingDeadlines.length} + levels +
+ +
+
+ Avg Time/Level + + {upcomingDeadlines.length > 0 + ? (kpis.tatEfficiency.avgCycleTimeHours / upcomingDeadlines.length).toFixed(1) + : '0'}h + +
+
+ At Risk + + {criticalRequests.filter(r => r.breachCount > 0).length} + +
+
+
+
+
+ + {/* Collaboration Stats */} + + +
+
+ +
+ Collaboration +
+
+ +
+
+
+

Notes

+

{kpis.engagement.workNotesAdded}

+
+
+

Files

+

{kpis.engagement.attachmentsUploaded}

+
+
+ +
+ {kpis.requestVolume.totalRequests > 0 + ? (kpis.engagement.workNotesAdded / kpis.requestVolume.totalRequests).toFixed(1) + : '0'} avg notes per request +
+
+
+
+ + {/* Department Activity */} + + +
+
+ +
+ Departments +
+
+ +
+
+ {departmentStats.length} + active +
+ +
+ {departmentStats[0] ? ( +
+
+ Top: {departmentStats[0].department} +
+
+ Requests: + {departmentStats[0].totalRequests} +
+
+ ) : ( + 'No data' + )} +
+
+
+
+ + {/* AI Adoption */} + + +
+
+ +
+ AI Adoption +
+
+ +
+
+ {kpis.aiInsights.aiSummaryAdoptionPercent}% +
+ +
+
+

AI

+

{kpis.aiInsights.aiGeneratedCount}

+
+
+

Manual

+

{kpis.aiInsights.manualCount}

+
+
+
+
+
+
+ )} + + {/* NORMAL USER - Personal Metrics */} + {!isAdmin && kpis && ( +
+ {/* Collaboration Activity */} + + +
+
+ +
+ My Activity +
+
+ +
+
+
+

Work Notes

+

{kpis.engagement.workNotesAdded}

+
+
+

Attachments

+

{kpis.engagement.attachmentsUploaded}

+
+
+
+
+
+ + {/* Avg Response Time */} + + +
+
+ +
+ Avg Response Time +
+
+ +
+
+ + {kpis.tatEfficiency.avgCycleTimeHours.toFixed(1)} + + hours +
+ +
+ ≈ {kpis.tatEfficiency.avgCycleTimeDays.toFixed(1)} working days +
+
+
+
+
+ )} + + {/* Department-wise Workflow Summary */} + {isAdmin && departmentStats.length > 0 && ( + + +
+
+ +
+ Department-wise Summary + + Workflow distribution across departments + +
+
+ +
+
+ +
+ + + + + + + + + + + + + {departmentStats.map((dept, idx) => ( + + + + + + + + + ))} + +
DepartmentTotalApprovedRejectedPendingRate
{dept.department}{dept.totalRequests}{dept.approved}{dept.rejected}{dept.inProgress} + = 80 ? 'bg-green-50 text-green-700 border-green-200' : 'bg-yellow-50 text-yellow-700 border-yellow-200'}`}> + {dept.approvalRate}% + +
+
+
+
+ )} + + {/* Priority Distribution Report */} + {isAdmin && priorityDistribution.length > 0 && ( + + +
+
+ +
+ Priority Distribution + + Express vs Standard performance analysis + +
+
+ +
+
+ +
+ {priorityDistribution.map((priority, idx) => { + const avgCycleTime = Number(priority.avgCycleTimeHours) || 0; + const complianceRate = Number(priority.complianceRate) || 0; + + return ( +
+
+

+ {priority.priority === 'express' && } + {priority.priority === 'standard' && } + {priority.priority} Priority +

+ {priority.totalCount} requests +
+
+
+
+ Avg Cycle Time + {avgCycleTime.toFixed(1)} hours +
+ +
+
+
+

Approved

+

{priority.approvedCount || 0}

+
+
+

Breached

+

{priority.breachedCount || 0}

+
+
+
+
+ TAT Compliance + = 80 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}> + {complianceRate}% + +
+
+
+
+ ); + })} +
+
+
+ )} + + {/* TAT Breach Report */} + {isAdmin && criticalRequests.length > 0 && ( + + +
+
+ +
+ TAT Breach Report + + Requests breached or approaching limits + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + {criticalRequests.map((req, idx) => ( + + + + + + + + + + ))} + +
Request IDTitlePriorityLevelBreachesTAT StatusAction
onNavigate?.(`request/${req.requestNumber}`)}> + {req.requestNumber} + {req.title} + + {req.priority} + + {req.currentLevel}/{req.totalLevels} + {req.breachCount > 0 ? ( + {req.breachCount} + ) : ( + - + )} + + {req.breachCount > 0 ? ( +
+ + {req.breachCount} Breached + + {req.totalTATHours > 0 && ( + + Current: {formatRemainingTime(req)} + + )} +
+ ) : ( + + {formatRemainingTime(req)} + + )} +
+ +
+
+
+
+ )} + + {/* Upcoming Deadlines / Workflow Aging */} + {upcomingDeadlines.length > 0 && ( + + +
+
+ +
+ Upcoming Deadlines + + {isAdmin ? 'Organization-wide aging workflows' : 'My pending approvals'} + +
+
+
+
+ +
+ {upcomingDeadlines.map((deadline, idx) => { + const tatPercentage = Number(deadline.tatPercentageUsed) || 0; + const elapsedHours = Number(deadline.elapsedHours) || 0; + const remainingHours = Number(deadline.remainingHours) || 0; + + return ( +
onNavigate?.(`request/${deadline.requestNumber}`)}> +
+
+
+ {deadline.requestNumber} + + {deadline.priority} + +
+

{deadline.requestTitle}

+

+ Level {deadline.levelNumber} • {deadline.approverName} +

+
+
+

TAT Used

+

= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'}`}> + {tatPercentage.toFixed(0)}% +

+
+
+
+ = 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'}`} + /> +
+ {elapsedHours.toFixed(1)}h elapsed + {remainingHours.toFixed(1)}h left +
+
+
+ ); + })} +
+
+
+ )}
); } \ No newline at end of file diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts new file mode 100644 index 0000000..f2565c3 --- /dev/null +++ b/src/services/dashboard.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +