diff --git a/src/App.tsx b/src/App.tsx index 93ba1cb..ef57e0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import { WorkNotes } from '@/pages/WorkNotes'; import { CreateRequest } from '@/pages/CreateRequest'; import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard'; import { MyRequests } from '@/pages/MyRequests'; +import { Requests } from '@/pages/Requests/Requests'; +import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance'; import { Profile } from '@/pages/Profile'; import { Settings } from '@/pages/Settings'; import { Notifications } from '@/pages/Notifications'; @@ -70,7 +72,13 @@ function AppRoutes({ onLogout }: AppProps) { navigate('/settings'); return; } - navigate(`/${page}`); + // If page already starts with '/', use it directly (e.g., '/requests?status=approved') + // Otherwise, add leading slash (e.g., 'open-requests' -> '/open-requests') + if (page.startsWith('/')) { + navigate(page); + } else { + navigate(`/${page}`); + } }; const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => { @@ -491,6 +499,26 @@ function AppRoutes({ onLogout }: AppProps) { } /> + {/* Requests - Advanced Filtering Screen (Admin/Management) */} + + + + } + /> + + {/* Approver Performance - Detailed Performance Analysis */} + + + + } + /> + {/* Request Detail - requestId will be read from URL params */}
-
+
diff --git a/src/components/admin/ConfigurationManager.tsx b/src/components/admin/ConfigurationManager.tsx index 5a9fb1a..5bcbdd7 100644 --- a/src/components/admin/ConfigurationManager.tsx +++ b/src/components/admin/ConfigurationManager.tsx @@ -259,23 +259,17 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro }; const getCategoryColor = (category: string) => { - switch (category) { - case 'TAT_SETTINGS': - return 'bg-blue-100 text-blue-600'; - case 'DOCUMENT_POLICY': - return 'bg-purple-100 text-purple-600'; - case 'NOTIFICATION_RULES': - return 'bg-amber-100 text-amber-600'; - case 'AI_CONFIGURATION': - return 'bg-pink-100 text-pink-600'; - case 'WORKFLOW_SHARING': - return 'bg-emerald-100 text-emerald-600'; - default: - return 'bg-gray-100 text-gray-600'; - } + // Use uniform slate color for all category icons + return 'bg-gradient-to-br from-slate-600 to-slate-700 text-white'; }; - const groupedConfigs = configurations.reduce((acc, config) => { + // Filter out notification rules and dashboard layout categories + const excludedCategories = ['NOTIFICATION_RULES', 'DASHBOARD_LAYOUT']; + const filteredConfigurations = configurations.filter( + config => !excludedCategories.includes(config.configCategory) + ); + + const groupedConfigs = filteredConfigurations.reduce((acc, config) => { if (!acc[config.configCategory]) { acc[config.configCategory] = []; } @@ -299,7 +293,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro ); } - if (configurations.length === 0) { + if (filteredConfigurations.length === 0) { return ( @@ -368,8 +362,10 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
-
+
+
{getCategoryIcon(category)} +
@@ -420,7 +416,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro size="sm" onClick={() => handleSave(config)} disabled={!hasChanges(config) || saving === config.configKey} - className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" + className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" > {saving === config.configKey ? ( <> diff --git a/src/components/admin/DocumentConfig/DocumentConfig.tsx b/src/components/admin/DocumentConfig/DocumentConfig.tsx index e5325f5..e8ab1d5 100644 --- a/src/components/admin/DocumentConfig/DocumentConfig.tsx +++ b/src/components/admin/DocumentConfig/DocumentConfig.tsx @@ -93,7 +93,7 @@ export function DocumentConfig() {
-
+
diff --git a/src/components/admin/HolidayManager.tsx b/src/components/admin/HolidayManager.tsx index 8b33923..66fc36c 100644 --- a/src/components/admin/HolidayManager.tsx +++ b/src/components/admin/HolidayManager.tsx @@ -198,7 +198,7 @@ export function HolidayManager() {
-
+
@@ -223,7 +223,7 @@ export function HolidayManager() { diff --git a/src/components/admin/TATConfig/TATConfig.tsx b/src/components/admin/TATConfig/TATConfig.tsx index 4f3498b..b67ea8a 100644 --- a/src/components/admin/TATConfig/TATConfig.tsx +++ b/src/components/admin/TATConfig/TATConfig.tsx @@ -114,7 +114,7 @@ export function TATConfig() {
-
+
diff --git a/src/components/admin/UserRoleManager/UserRoleManager.tsx b/src/components/admin/UserRoleManager/UserRoleManager.tsx index e6561e7..9811393 100644 --- a/src/components/admin/UserRoleManager/UserRoleManager.tsx +++ b/src/components/admin/UserRoleManager/UserRoleManager.tsx @@ -46,7 +46,20 @@ interface OktaUser { lastName?: string; displayName?: string; department?: string; + phone?: string; + mobilePhone?: string; designation?: string; + jobTitle?: string; + manager?: string; + employeeId?: string; + employeeNumber?: string; + secondEmail?: string; + location?: { + state?: string; + city?: string; + country?: string; + office?: string; + }; } interface UserWithRole { @@ -124,10 +137,41 @@ export function UserRoleManager() { }; // Select user from search results - const handleSelectUser = (user: OktaUser) => { + const handleSelectUser = async (user: OktaUser) => { setSelectedUser(user); setSearchQuery(user.email); setSearchResults([]); + + // Check if user already exists in the current users list and has a role assigned + const existingUser = users.find(u => + u.email.toLowerCase() === user.email.toLowerCase() || + u.userId === user.userId + ); + + if (existingUser && existingUser.role) { + // Pre-select the user's current role + setSelectedRole(existingUser.role); + } else { + // If user doesn't exist in current list, check all users in database + try { + const allUsers = await userApi.getAllUsers(); + const foundUser = allUsers.find((u: any) => + (u.email && u.email.toLowerCase() === user.email.toLowerCase()) || + (u.userId && u.userId === user.userId) + ); + + if (foundUser && foundUser.role) { + setSelectedRole(foundUser.role); + } else { + // Default to USER if user doesn't exist + setSelectedRole('USER'); + } + } catch (error) { + console.error('Failed to check user role:', error); + // Default to USER on error + setSelectedRole('USER'); + } + } }; // Assign role to user @@ -142,7 +186,8 @@ export function UserRoleManager() { try { // Call backend to assign role (will create user if doesn't exist) - await userApi.assignRole(selectedUser.email, selectedRole); + // Pass full user data so backend can capture all Okta fields + await userApi.assignRole(selectedUser.email, selectedRole, selectedUser); setMessage({ type: 'success', @@ -282,22 +327,22 @@ export function UserRoleManager() { const getRoleBadgeColor = (role: string) => { switch (role) { case 'ADMIN': - return 'bg-yellow-400 text-slate-900'; + return 'bg-yellow-400 text-slate-800'; case 'MANAGEMENT': - return 'bg-blue-400 text-slate-900'; + return 'bg-blue-400 text-slate-800'; default: - return 'bg-gray-400 text-white'; + return 'bg-gray-400 text-slate-800'; } }; const getRoleIcon = (role: string) => { switch (role) { case 'ADMIN': - return ; + return ; case 'MANAGEMENT': - return ; + return ; default: - return ; + return ; } }; @@ -322,7 +367,7 @@ export function UserRoleManager() {

- +
@@ -345,7 +390,7 @@ export function UserRoleManager() {

- +
@@ -368,7 +413,7 @@ export function UserRoleManager() {

- +
@@ -379,7 +424,7 @@ export function UserRoleManager() {
-
+
@@ -442,19 +487,19 @@ export function UserRoleManager() { {/* Selected User */} {selectedUser && ( -
+
-
+
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
-

+

{selectedUser.displayName || selectedUser.email}

-

{selectedUser.email}

+

{selectedUser.email}

{selectedUser.department && ( -

+

{selectedUser.department}{selectedUser.designation ? ` β€’ ${selectedUser.designation}` : ''}

)} @@ -467,7 +512,7 @@ export function UserRoleManager() { setSelectedUser(null); setSearchQuery(''); }} - className="hover:bg-purple-100" + className="hover:bg-slate-200" > Clear @@ -512,7 +557,7 @@ export function UserRoleManager() { + + + + ); +} + diff --git a/src/components/participant/AddApproverModal/AddApproverModal.tsx b/src/components/participant/AddApproverModal/AddApproverModal.tsx index 1090702..fa53f82 100644 --- a/src/components/participant/AddApproverModal/AddApproverModal.tsx +++ b/src/components/participant/AddApproverModal/AddApproverModal.tsx @@ -210,7 +210,16 @@ export function AddApproverModal({ displayName: foundUser.displayName, firstName: foundUser.firstName, lastName: foundUser.lastName, - department: foundUser.department + department: foundUser.department, + phone: foundUser.phone, + mobilePhone: foundUser.mobilePhone, + designation: foundUser.designation, + jobTitle: foundUser.jobTitle, + manager: foundUser.manager, + employeeId: foundUser.employeeId, + employeeNumber: foundUser.employeeNumber, + secondEmail: foundUser.secondEmail, + location: foundUser.location }); console.log(`βœ… Validated approver: ${foundUser.displayName} (${foundUser.email})`); @@ -333,7 +342,16 @@ export function AddApproverModal({ displayName: user.displayName, firstName: user.firstName, lastName: user.lastName, - department: user.department + department: user.department, + phone: user.phone, + mobilePhone: user.mobilePhone, + designation: user.designation, + jobTitle: user.jobTitle, + manager: user.manager, + employeeId: user.employeeId, + employeeNumber: user.employeeNumber, + secondEmail: user.secondEmail, + location: user.location }); setEmail(user.email); diff --git a/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx b/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx index 8c8f88d..22a7e20 100644 --- a/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx +++ b/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx @@ -5,6 +5,8 @@ import { Input } from '@/components/ui/input'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react'; import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi'; +import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi'; +import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal'; interface AddSpectatorModalProps { open: boolean; @@ -42,6 +44,57 @@ export function AddSpectatorModal({ message: '' }); + // Policy violation modal state + const [policyViolationModal, setPolicyViolationModal] = useState<{ + open: boolean; + violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>; + }>({ + open: false, + violations: [] + }); + + // System policy configuration state + const [systemPolicy, setSystemPolicy] = useState<{ + maxApprovalLevels: number; + maxParticipants: number; + allowSpectators: boolean; + maxSpectators: number; + }>({ + maxApprovalLevels: 10, + maxParticipants: 50, + allowSpectators: true, + maxSpectators: 20 + }); + + // Fetch system policy on mount + useEffect(() => { + const loadSystemPolicy = async () => { + try { + const workflowConfigs = await getAllConfigurations('WORKFLOW_SHARING'); + const tatConfigs = await getAllConfigurations('TAT_SETTINGS'); + const allConfigs = [...workflowConfigs, ...tatConfigs]; + const configMap: Record = {}; + allConfigs.forEach((c: AdminConfiguration) => { + configMap[c.configKey] = c.configValue; + }); + + setSystemPolicy({ + maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'), + maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'), + allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true', + maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20') + }); + } catch (error) { + console.error('Failed to load system policy:', error); + // Use defaults if loading fails + } + }; + + if (open) { + loadSystemPolicy(); + } + }, [open]); + const handleConfirm = async () => { const emailToAdd = email.trim().toLowerCase(); @@ -111,6 +164,58 @@ export function AddSpectatorModal({ } } + // Policy validation before adding spectator + const violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }> = []; + + // Check if spectators are allowed + if (!systemPolicy.allowSpectators) { + violations.push({ + type: 'Spectators Not Allowed', + message: `Adding spectators is not allowed by system policy.`, + }); + } + + // Count existing spectators + const existingSpectators = existingParticipants.filter( + p => (p.participantType || '').toUpperCase() === 'SPECTATOR' + ); + const currentSpectatorCount = existingSpectators.length; + + // Check maximum spectators + if (currentSpectatorCount >= systemPolicy.maxSpectators) { + violations.push({ + type: 'Maximum Spectators Exceeded', + message: `This request has reached the maximum number of spectators allowed.`, + currentValue: currentSpectatorCount, + maxValue: systemPolicy.maxSpectators + }); + } + + // Count existing participants (initiator + approvers + spectators) + const existingApprovers = existingParticipants.filter( + p => (p.participantType || '').toUpperCase() === 'APPROVER' + ); + const totalParticipants = existingParticipants.length + 1; // +1 for the new spectator + + // Check maximum participants + if (totalParticipants > systemPolicy.maxParticipants) { + violations.push({ + type: 'Maximum Participants Exceeded', + message: `Adding this spectator would exceed the maximum participants limit.`, + currentValue: totalParticipants, + maxValue: systemPolicy.maxParticipants + }); + } + + // If there are policy violations, show modal and return + if (violations.length > 0) { + setPolicyViolationModal({ + open: true, + violations + }); + return; + } + // If user was NOT selected via @ search, validate against Okta if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) { try { @@ -137,7 +242,16 @@ export function AddSpectatorModal({ displayName: foundUser.displayName, firstName: foundUser.firstName, lastName: foundUser.lastName, - department: foundUser.department + department: foundUser.department, + phone: foundUser.phone, + mobilePhone: foundUser.mobilePhone, + designation: foundUser.designation, + jobTitle: foundUser.jobTitle, + manager: foundUser.manager, + employeeId: foundUser.employeeId, + employeeNumber: foundUser.employeeNumber, + secondEmail: foundUser.secondEmail, + location: foundUser.location }); console.log(`βœ… Validated spectator: ${foundUser.displayName} (${foundUser.email})`); @@ -246,7 +360,16 @@ export function AddSpectatorModal({ displayName: user.displayName, firstName: user.firstName, lastName: user.lastName, - department: user.department + department: user.department, + phone: user.phone, + mobilePhone: user.mobilePhone, + designation: user.designation, + jobTitle: user.jobTitle, + manager: user.manager, + employeeId: user.employeeId, + employeeNumber: user.employeeNumber, + secondEmail: user.secondEmail, + location: user.location }); setEmail(user.email); @@ -445,6 +568,19 @@ export function AddSpectatorModal({ + + {/* Policy Violation Modal */} + setPolicyViolationModal({ open: false, violations: [] })} + violations={policyViolationModal.violations} + policyDetails={{ + maxApprovalLevels: systemPolicy.maxApprovalLevels, + maxParticipants: systemPolicy.maxParticipants, + allowSpectators: systemPolicy.allowSpectators, + maxSpectators: systemPolicy.maxSpectators + }} + /> ); } diff --git a/src/components/sla/SLAProgressBar/SLAProgressBar.tsx b/src/components/sla/SLAProgressBar/SLAProgressBar.tsx index cb154d2..967fe12 100644 --- a/src/components/sla/SLAProgressBar/SLAProgressBar.tsx +++ b/src/components/sla/SLAProgressBar/SLAProgressBar.tsx @@ -1,6 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react'; +import { formatHoursMinutes } from '@/utils/slaTracker'; export interface SLAData { status: 'normal' | 'approaching' | 'critical' | 'breached'; @@ -73,7 +74,7 @@ export function SLAProgressBar({
- {sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed + {sla.elapsedText || formatHoursMinutes(sla.elapsedHours || 0)} elapsed - {sla.remainingText || `${sla.remainingHours || 0}h`} remaining + {sla.remainingText || formatHoursMinutes(sla.remainingHours || 0)} remaining
diff --git a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx index ab4c24e..74f7b63 100644 --- a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx +++ b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx @@ -105,9 +105,20 @@ const getStatusText = (status: string) => { const formatMessage = (content: string) => { // Enhanced mention highlighting - Blue color with extra bold font for high visibility - // Matches: @test user11 or @Test User11 (any case, stops before next sentence/punctuation) + // Matches: @username or @FirstName LastName (only one space allowed for first name + last name) + // Pattern: @word or @word word (stops after second word) return content - .replace(/@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g, '@$1') + .replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => { + const afterPos = offset + match.length; + const afterChar = string[afterPos]; + + // Valid mention if followed by: space, punctuation, @ (another mention), or end of string + if (!afterChar || /\s|[.,!?;:]|@/.test(afterChar)) { + return '@' + mention + ''; + } + + return match; + }) .replace(/\n/g, '
'); }; @@ -165,7 +176,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk errors: [] }); - console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current); + // Component render - logging removed for performance // Get request info (from props, all data comes from backend now) const requestInfo = useMemo(() => { @@ -182,13 +193,9 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk msg.user.name.toLowerCase().includes(searchTerm.toLowerCase()) ); - // Log when participants change + // Log when participants change - logging removed for performance useEffect(() => { - console.log('[WorkNoteChat] Participants state changed:', { - total: participants.length, - online: participants.filter(p => p.status === 'online').length, - participants: participants.map(p => ({ name: p.name, status: p.status, userId: (p as any).userId })) - }); + // Participants state changed - logging removed }, [participants]); // Load initial messages from backend (only if not provided by parent) @@ -340,7 +347,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk } as any; }); - console.log('[WorkNoteChat] βœ… Loaded participants:', mapped.map(p => ({ name: p.name, userId: (p as any).userId }))); + // Participants loaded - logging removed participantsLoadedRef.current = true; setParticipants(mapped); @@ -349,7 +356,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const maxRetries = 3; const requestOnlineUsers = () => { if (socketRef.current && socketRef.current.connected) { - console.log('[WorkNoteChat] πŸ“‘ Requesting online users list (attempt', retryCount + 1, ')...'); + // Requesting online users - logging removed socketRef.current.emit('request:online-users', { requestId: effectiveRequestId }); retryCount++; // Retry a few times to ensure we get the list @@ -357,7 +364,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk setTimeout(requestOnlineUsers, 500); } } else { - console.log('[WorkNoteChat] ⚠️ Socket not ready, will retry in 200ms... (attempt', retryCount + 1, ')'); + // Socket not ready - retrying silently retryCount++; if (retryCount < maxRetries) { setTimeout(requestOnlineUsers, 200); @@ -574,31 +581,27 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk // Handle initial online users list const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => { - console.log('[WorkNoteChat] πŸ“‹ presence:online received - requestId:', data.requestId, 'onlineUserIds:', data.userIds, 'count:', data.userIds.length); + // Presence update received - logging removed setParticipants(prev => { if (prev.length === 0) { console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status. Response will be ignored.'); return prev; } - console.log('[WorkNoteChat] πŸ“Š Updating online status for', prev.length, 'participants'); + // Updating online status - logging removed const updated = prev.map(p => { const pUserId = (p as any).userId || ''; const isCurrentUserSelf = pUserId === currentUserId; // Always keep self as online in own browser if (isCurrentUserSelf) { - console.log(`[WorkNoteChat] 🟒 ${p.name} (YOU - always online in own view)`); return { ...p, status: 'online' as const }; } const isOnline = data.userIds.includes(pUserId); - console.log(`[WorkNoteChat] ${isOnline ? '🟒' : 'βšͺ'} ${p.name} (userId: ${pUserId.slice(0, 8)}...): ${isOnline ? 'ONLINE' : 'offline'}`); return { ...p, status: isOnline ? 'online' as const : 'offline' as const }; }); - const onlineCount = updated.filter(p => p.status === 'online').length; - console.log('[WorkNoteChat] βœ… Online status updated: ', onlineCount, '/', updated.length, 'participants online'); - console.log('[WorkNoteChat] πŸ“‹ Online participants:', updated.filter(p => p.status === 'online').map(p => p.name).join(', ')); + // Online status updated - logging removed return updated; }); }; @@ -652,13 +655,13 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk }; // Debug: Log ALL events received from server for this request - const anyEventHandler = (eventName: string, ...args: any[]) => { + const anyEventHandler = (eventName: string) => { if (eventName.includes('presence') || eventName.includes('worknote') || eventName.includes('request')) { - console.log('[WorkNoteChat] πŸ“¨ Event received:', eventName, args); + // Socket event received - logging removed } }; - console.log('[WorkNoteChat] πŸ”Œ Attaching socket listeners for request:', joinedId); + // Attaching socket listeners - logging removed s.on('connect', connectHandler); s.on('disconnect', disconnectHandler); s.on('error', errorHandler); @@ -667,35 +670,26 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk s.on('presence:leave', presenceLeaveHandler); s.on('presence:online', presenceOnlineHandler); s.onAny(anyEventHandler); // Debug: catch all events - console.log('[WorkNoteChat] βœ… All socket listeners attached (including error handlers)'); + // Socket listeners attached - logging removed // Store socket in ref for coordination with participants loading socketRef.current = s; // Always request online users after socket is ready - console.log('[WorkNoteChat] πŸ”Œ Socket ready and listeners attached, socket.connected:', s.connected); if (s.connected) { if (participantsLoadedRef.current) { - console.log('[WorkNoteChat] πŸ“‘ Participants already loaded, requesting online users now (with retries)'); - // Send multiple requests to ensure we get the response + // Requesting online users with retries - logging removed s.emit('request:online-users', { requestId: joinedId }); setTimeout(() => { - console.log('[WorkNoteChat] πŸ“‘ Retry 1: Requesting online users...'); s.emit('request:online-users', { requestId: joinedId }); }, 300); setTimeout(() => { - console.log('[WorkNoteChat] πŸ“‘ Retry 2: Requesting online users...'); s.emit('request:online-users', { requestId: joinedId }); }, 800); setTimeout(() => { - console.log('[WorkNoteChat] πŸ“‘ Final retry: Requesting online users...'); s.emit('request:online-users', { requestId: joinedId }); }, 1500); - } else { - console.log('[WorkNoteChat] ⏳ Waiting for participants to load first...'); } - } else { - console.log('[WorkNoteChat] ⏳ Socket not connected yet, will request online users on connect event'); } // cleanup @@ -714,7 +708,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk console.log('[WorkNoteChat] πŸšͺ Emitting leave:request for room (standalone mode)'); } socketRef.current = null; - console.log('[WorkNoteChat] 🧹 Cleaned up all socket listeners and left room'); + // Socket cleanup completed - logging removed }; (window as any).__wn_cleanup = cleanup; } catch {} @@ -740,11 +734,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk }) .filter(Boolean); - console.log('[WorkNoteChat] πŸ“ MESSAGE:', message); - console.log('[WorkNoteChat] πŸ‘₯ ALL PARTICIPANTS:', participants.map(p => ({ name: p.name, userId: (p as any)?.userId }))); - console.log('[WorkNoteChat] 🎯 MENTIONS EXTRACTED:', mentions); - console.log('[WorkNoteChat] πŸ†” USER IDS FOUND:', mentionedUserIds); - console.log('[WorkNoteChat] πŸ“€ SENDING TO BACKEND:', { message, mentions: mentionedUserIds }); + // Message sending - logging removed const attachments = selectedFiles.map(file => ({ name: file.name, @@ -878,7 +868,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); - console.log('[WorkNoteChat] Mapped and sorted messages:', sorted.length, 'total'); + // Messages mapped - logging removed setMessages(sorted); } catch (err) { console.error('[WorkNoteChat] Error mapping messages:', err); @@ -1158,12 +1148,21 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const extractMentions = (text: string): string[] => { // Use the SAME regex pattern as formatMessage to ensure consistency - const mentionRegex = /@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g; + // Only one space allowed: @word or @word word (first name + last name) + const mentionRegex = /@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g; const mentions: string[] = []; let match; while ((match = mentionRegex.exec(text)) !== null) { if (match[1]) { - mentions.push(match[1].trim()); + // Check if this is a valid mention (followed by space, punctuation, @, or end) + const afterPos = match.index + match[0].length; + const afterText = text.slice(afterPos); + const afterChar = text[afterPos]; + + // Valid if followed by: @ (another mention), space, punctuation, or end + if (afterText.startsWith('@') || !afterChar || /\s|[.,!?;:]|@/.test(afterChar)) { + mentions.push(match[1].trim()); + } } } console.log('[Extract Mentions] Found:', mentions, 'from text:', text); @@ -1499,34 +1498,43 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
{/* Mention Suggestions Dropdown - Shows above textarea */} {(() => { + // Find the last @ symbol that hasn't been completed (doesn't have a space after a name) const lastAtIndex = message.lastIndexOf('@'); const hasAt = lastAtIndex >= 0; - const textAfterAt = hasAt ? message.slice(lastAtIndex + 1) : ''; - // Don't show if: - // 1. No @ found - // 2. Text after @ is too long (>20 chars) - // 3. Text after @ ends with a space (completed mention) - // 4. Text after @ contains a space (already selected a user) + if (!hasAt) return null; + + // Get text after the last @ + const textAfterAt = message.slice(lastAtIndex + 1); + + // Check if this mention is already completed + // A completed mention looks like: "@username " (ends with space after name) + // An incomplete mention looks like: "@" or "@user" (no space after, or just typed @) + const trimmedAfterAt = textAfterAt.trim(); const endsWithSpace = textAfterAt.endsWith(' '); - const containsSpace = textAfterAt.trim().includes(' '); + const hasNonSpaceChars = trimmedAfterAt.length > 0; + + // Don't show dropdown if: + // 1. Text after @ is too long (>20 chars) - probably not a mention + // 2. Text after @ ends with space AND has characters (completed mention like "@user ") + // 3. Text after @ contains space in the middle (like "@user name" - multi-word name already typed) + const containsSpaceInMiddle = trimmedAfterAt.includes(' ') && !endsWithSpace; + const isCompletedMention = endsWithSpace && hasNonSpaceChars; + + // Show dropdown if: + // - Has @ symbol + // - Text after @ is not too long + // - Mention is not completed (doesn't end with space after a name) + // - Doesn't contain space in middle (not a multi-word name being typed) const shouldShowDropdown = hasAt && textAfterAt.length <= 20 && - !endsWithSpace && - !containsSpace; - - console.log('[Mention Debug]', { - hasAt, - textAfterAt: `"${textAfterAt}"`, - endsWithSpace, - containsSpace, - shouldShowDropdown, - participantsCount: participants.length - }); + !containsSpaceInMiddle && + !isCompletedMention; if (!shouldShowDropdown) return null; - const searchTerm = textAfterAt.toLowerCase(); + // Use trimmed text for search (ignore trailing spaces) + const searchTerm = trimmedAfterAt.toLowerCase(); const filteredParticipants = participants.filter(p => { // Exclude current user from mention suggestions const isCurrentUserInList = (p as any).userId === currentUserId; @@ -1539,8 +1547,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk return true; // Show all if no search term }); - console.log('[Mention Debug] Filtered participants:', filteredParticipants.length); - return (

πŸ’¬ Mention someone

@@ -1553,8 +1559,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk onClick={(e) => { e.preventDefault(); e.stopPropagation(); + // Find the last @ and replace everything from @ to end with the new mention const lastAt = message.lastIndexOf('@'); const before = message.slice(0, lastAt); + // Add the mention with a space after for easy continuation setMessage(before + '@' + participant.name + ' '); }} className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200" diff --git a/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx b/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx index 22dcb0b..5ace43f 100644 --- a/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx +++ b/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx @@ -1,9 +1,16 @@ +import { useState, useEffect } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon, FileEdit } from 'lucide-react'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; +import { formatHoursMinutes } from '@/utils/slaTracker'; +import { useAuth, hasManagementAccess } from '@/contexts/AuthContext'; +import { updateBreachReason as updateBreachReasonApi } from '@/services/workflowApi'; +import { toast } from 'sonner'; export interface ApprovalStep { step: number; @@ -33,6 +40,7 @@ interface ApprovalStepCardProps { isCurrentUser?: boolean; isInitiator?: boolean; onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void; + onRefresh?: () => void | Promise; // Optional callback to refresh data testId?: string; } @@ -40,12 +48,12 @@ interface ApprovalStepCardProps { const formatWorkingHours = (hours: number): string => { const WORKING_HOURS_PER_DAY = 8; if (hours < WORKING_HOURS_PER_DAY) { - return `${hours.toFixed(1)}h`; + return formatHoursMinutes(hours); } const days = Math.floor(hours / WORKING_HOURS_PER_DAY); const remainingHours = hours % WORKING_HOURS_PER_DAY; if (remainingHours > 0) { - return `${days}d ${remainingHours.toFixed(1)}h`; + return `${days}d ${formatHoursMinutes(remainingHours)}`; } return `${days}d`; }; @@ -75,8 +83,24 @@ export function ApprovalStepCard({ isCurrentUser = false, isInitiator = false, onSkipApprover, + onRefresh, testId = 'approval-step' }: ApprovalStepCardProps) { + const { user } = useAuth(); + const [showBreachReasonModal, setShowBreachReasonModal] = useState(false); + const [breachReason, setBreachReason] = useState(''); + const [savingReason, setSavingReason] = useState(false); + + // Get existing breach reason from approval or step data + const existingBreachReason = (approval as any)?.breachReason || (step as any)?.breachReason || ''; + + // Reset modal state when it closes + useEffect(() => { + if (!showBreachReasonModal) { + setBreachReason(''); + } + }, [showBreachReasonModal]); + const isActive = step.status === 'pending' || step.status === 'in-review'; const isCompleted = step.status === 'approved'; const isRejected = step.status === 'rejected'; @@ -85,6 +109,56 @@ export function ApprovalStepCard({ const tatHours = Number(step.tatHours || 0); const actualHours = step.actualHours; const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0; + + // Calculate if breached + const progressPercentage = tatHours > 0 ? (actualHours / tatHours) * 100 : 0; + const isBreached = progressPercentage >= 100; + + // Check permissions: ADMIN, MANAGEMENT, or the approver + const isAdmin = user?.role === 'ADMIN'; + const isManagement = hasManagementAccess(user); + const isApprover = step.approverId === user?.userId; + const canEditBreachReason = isAdmin || isManagement || isApprover; + + const handleSaveBreachReason = async () => { + if (!breachReason.trim()) { + toast.error('Breach Reason Required', { + description: 'Please enter a reason for the breach.', + }); + return; + } + + setSavingReason(true); + try { + await updateBreachReasonApi(step.levelId, breachReason.trim()); + setShowBreachReasonModal(false); + setBreachReason(''); + + toast.success('Breach Reason Updated', { + description: 'The breach reason has been saved and will appear in the TAT Breach Report.', + duration: 5000, + }); + + // Refresh data if callback provided, otherwise reload page + if (onRefresh) { + await onRefresh(); + } else { + // Fallback to page reload if no refresh callback + setTimeout(() => { + window.location.reload(); + }, 1000); + } + } catch (error: any) { + console.error('Error updating breach reason:', error); + const errorMessage = error?.response?.data?.error || error?.message || 'Failed to update breach reason. Please try again.'; + toast.error('Failed to Update Breach Reason', { + description: errorMessage, + duration: 5000, + }); + } finally { + setSavingReason(false); + } + }; return (
{ // Calculate actual progress percentage based on time used // If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069% - const progressPercentage = tatHours > 0 ? Math.min(100, (actualHours / tatHours) * 100) : 0; + const displayPercentage = Math.min(100, progressPercentage); return ( <> div]:bg-red-600' : '[&>div]:bg-green-600'}`} data-testid={`${testId}-progress-bar`} />
- - {progressPercentage.toFixed(1)}% of TAT used - +
+ + {Math.round(displayPercentage)}% of TAT used + + {isBreached && canEditBreachReason && ( + + + + + + +

{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}

+
+
+
+ )} +
{savedHours > 0 && ( - Saved {savedHours.toFixed(1)} hours + Saved {formatHoursMinutes(savedHours)} )}
@@ -211,6 +309,17 @@ export function ApprovalStepCard({ })()}
+ {/* Breach Reason Display for Completed Approver */} + {isBreached && existingBreachReason && ( +
+

+ + Breach Reason: +

+

{existingBreachReason}

+
+ )} + {/* Conclusion Remark */} {step.comment && (
@@ -252,7 +361,7 @@ export function ApprovalStepCard({
Time used: - {approval.sla.elapsedText} / {tatHours}h allocated + {approval.sla.elapsedText} / {formatHoursMinutes(tatHours)} allocated
@@ -268,22 +377,57 @@ export function ApprovalStepCard({ data-testid={`${testId}-sla-progress`} />
- - Progress: {approval.sla.percentageUsed}% of TAT used - +
+ + Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used + + {approval.sla.status === 'breached' && canEditBreachReason && ( + + + + + + +

{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}

+
+
+
+ )} +
{approval.sla.remainingText} remaining
{approval.sla.status === 'breached' && ( -

- - Deadline Breached -

+ <> +

+ + Deadline Breached +

+ {existingBreachReason && ( +
+

+ + Breach Reason: +

+

{existingBreachReason}

+
+ )} + )} {approval.sla.status === 'critical' && (

@@ -480,6 +624,51 @@ export function ApprovalStepCard({ )}

+ + {/* Breach Reason Modal */} + + + + {existingBreachReason ? 'Edit Breach Reason' : 'Add Breach Reason'} + + {existingBreachReason + ? 'Update the reason for the TAT breach. This will be reflected in the TAT Breach Report.' + : 'Please provide a reason for the TAT breach. This will be reflected in the TAT Breach Report.'} + + +
+