diff --git a/src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx b/src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx index bdc6c4f..18abbaf 100644 --- a/src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx +++ b/src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx @@ -1,6 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { Star } from 'lucide-react'; +import { formatBreachTime } from '@/pages/Dashboard/utils/dashboardCalculations'; export interface CriticalAlertData { requestId: string; @@ -12,6 +13,8 @@ export interface CriticalAlertData { breachCount: number; currentLevel: number; totalLevels: number; + isActionable?: boolean; + requestRole?: 'APPROVER' | 'INITIATOR' | 'PARTICIPANT'; } interface CriticalAlertCardProps { @@ -23,112 +26,131 @@ interface CriticalAlertCardProps { // Utility functions const calculateProgress = (alert: CriticalAlertData) => { if (!alert.originalTATHours || alert.originalTATHours === 0) return 0; - + const originalTAT = alert.originalTATHours; const remainingTAT = alert.totalTATHours; - + // If breached (negative remaining), show 100% if (remainingTAT <= 0) return 100; - + // Calculate elapsed time const elapsedTAT = originalTAT - remainingTAT; - + // Calculate percentage used const percentageUsed = (elapsedTAT / originalTAT) * 100; - + // Ensure it's between 0 and 100 return Math.min(100, Math.max(0, Math.round(percentageUsed))); }; -const formatRemainingTime = (alert: CriticalAlertData) => { +const formatDisplayTime = (alert: CriticalAlertData) => { if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A'; - + const hours = alert.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`; + const isOverdue = hours <= 0; + const absHours = Math.abs(hours); + + const formattedTime = formatBreachTime(absHours); + + if (formattedTime === 'Just breached') return 'Breached'; + + return isOverdue ? `${formattedTime} overdue` : `${formattedTime} left`; }; -export function CriticalAlertCard({ - alert, +const getRoleBadge = (role?: string) => { + switch (role) { + case 'APPROVER': + return { label: 'Action Required', className: 'bg-red-100 text-red-700 border-red-200' }; + case 'INITIATOR': + return { label: 'My Request', className: 'bg-orange-100 text-orange-700 border-orange-200' }; + default: + return { label: 'Monitoring', className: 'bg-blue-100 text-blue-700 border-blue-200' }; + } +}; + +export function CriticalAlertCard({ + alert, onNavigate, testId = 'critical-alert-card' }: CriticalAlertCardProps) { const progress = calculateProgress(alert); - + const isActionable = alert.isActionable ?? true; // Default to true if not provided (admin view) + const roleInfo = getRoleBadge(alert.requestRole); + return ( -
onNavigate?.(alert.requestNumber)} data-testid={`${testId}-${alert.requestId}`} >
-

{alert.requestNumber}

{alert.priority === 'express' && ( - )} + {alert.requestRole && ( + + {roleInfo.label} + + )} {alert.breachCount > 0 && ( - {alert.breachCount} )}
-

{alert.title}

- - {formatRemainingTime(alert)} + {formatDisplayTime(alert)}
TAT Used - {progress}%
- = 80 ? '[&>div]:bg-red-600' : - progress >= 50 ? '[&>div]:bg-orange-500' : - '[&>div]:bg-green-600' - }`} + = 80 ? '[&>div]:bg-red-600' : + progress >= 50 ? '[&>div]:bg-orange-500' : + '[&>div]:bg-green-600' + }`} data-testid={`${testId}-progress-bar`} />
diff --git a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx index f0db392..2a1fed6 100644 --- a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx +++ b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx @@ -15,13 +15,13 @@ import { ActionStatusModal } from '@/components/common/ActionStatusModal'; import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal'; import { AddApproverModal } from '@/components/participant/AddApproverModal'; import { formatDateTime } from '@/utils/dateFormatter'; -import { - Send, - Smile, - Paperclip, - Users, - FileText, - Download, +import { + Send, + Smile, + Paperclip, + Users, + FileText, + Download, Eye, MessageSquare, Clock, @@ -115,12 +115,12 @@ const formatMessage = (content: string) => { .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, '
'); @@ -198,11 +198,11 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const [participants, setParticipants] = useState([]); const onlineParticipants = participants.filter(p => p.status === 'online'); - const filteredMessages = messages.filter(msg => + const filteredMessages = messages.filter(msg => msg.content.toLowerCase().includes(searchTerm.toLowerCase()) || msg.user.name.toLowerCase().includes(searchTerm.toLowerCase()) ); - + // Determine if current user is a spectator (when not passed as prop) const effectiveIsSpectator = useMemo(() => { // If isSpectator is explicitly passed as prop, use it @@ -220,17 +220,17 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk return pUserId === currentUserId && (pRole === 'SPECTATOR' || pType === 'SPECTATOR'); }); }, [isSpectator, currentUserId, participants]); - + // Log when participants change - logging removed for performance useEffect(() => { // Participants state changed - logging removed }, [participants]); - + // Load initial messages from backend (only if not provided by parent) useEffect(() => { if (!effectiveRequestId || !currentUserId) return; if (externalMessages) return; // Skip if parent is providing messages - + (async () => { try { const rows = await getWorkNotes(effectiveRequestId); @@ -238,19 +238,19 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const noteUserId = m.userId || m.user_id; return { id: m.noteId || m.id || String(Math.random()), - user: { - name: m.userName || 'User', - avatar: (m.userName || 'U').slice(0,2).toUpperCase(), - role: m.userRole || 'Participant' + user: { + name: m.userName || 'User', + avatar: (m.userName || 'U').slice(0, 2).toUpperCase(), + role: m.userRole || 'Participant' }, content: m.message || '', timestamp: m.createdAt || new Date().toISOString(), isCurrentUser: noteUserId === currentUserId, - attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ + attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ attachmentId: a.attachmentId || a.attachment_id, name: a.fileName || a.file_name || a.name, fileName: a.fileName || a.file_name || a.name, - url: a.storageUrl || a.storage_url || a.url || '#', + url: a.storageUrl || a.storage_url || a.url || '#', type: a.fileType || a.file_type || a.type || 'file', fileType: a.fileType || a.file_type || a.type || 'file', fileSize: a.fileSize || a.file_size @@ -297,10 +297,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const existingParticipants = useMemo(() => { return participants.map(p => ({ email: (p.email || '').toLowerCase(), - participantType: p.role === 'Initiator' ? 'INITIATOR' : - p.role === 'Approver' ? 'APPROVER' : - p.role === 'Spectator' ? 'SPECTATOR' : - 'PARTICIPANT', + participantType: p.role === 'Initiator' ? 'INITIATOR' : + p.role === 'Approver' ? 'APPROVER' : + p.role === 'Spectator' ? 'SPECTATOR' : + 'PARTICIPANT', name: p.name })); }, [participants]); @@ -342,26 +342,26 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (participantsLoadedRef.current) { return; } - + if (!effectiveRequestId) { return; } - + (async () => { try { const details = await getWorkflowDetails(effectiveRequestId); const rows = Array.isArray(details?.participants) ? details.participants : []; - + if (rows.length === 0) { return; } - + const mapped: Participant[] = rows.map((p: any) => { const participantType = p.participantType || p.participant_type || 'participant'; const userId = p.userId || p.user_id || ''; return { name: p.userName || p.user_name || p.user_email || p.userEmail || 'User', - avatar: (p.userName || p.user_name || p.user_email || 'U').toString().split(' ').map((s: string)=>s[0]).filter(Boolean).join('').slice(0,2).toUpperCase(), + avatar: (p.userName || p.user_name || p.user_email || 'U').toString().split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), role: formatParticipantRole(participantType.toString()), status: 'offline', // will be updated by presence events email: p.userEmail || p.user_email || '', @@ -369,11 +369,11 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk userId: userId // store userId for presence matching } as any; }); - + // Participants loaded - logging removed participantsLoadedRef.current = true; setParticipants(mapped); - + // Request online users immediately after setting participants with retries let retryCount = 0; const maxRetries = 3; @@ -395,7 +395,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk } }; setTimeout(requestOnlineUsers, 100); // Initial delay to ensure state is updated - + } catch (error) { console.error('[WorkNoteChat] โŒ Failed to load participants:', error); } @@ -456,7 +456,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk // Realtime updates via Socket.IO (standalone usage OR when embedded in RequestDetail) useEffect(() => { if (!currentUserId) return; // Wait for currentUserId to be loaded - + let joinedId = effectiveRequestId; (async () => { try { @@ -464,18 +464,18 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (details?.workflow?.requestId) { joinedId = details.workflow.requestId; // join by UUID to match server emits } - } catch {} + } catch { } try { // Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL) const s = getSocket(); // Uses getSocketBaseUrl() helper internally - + // Only join room if not skipped (standalone mode) if (!skipSocketJoin) { joinRequestRoom(s, joinedId, currentUserId); - + // Mark self as online immediately after joining room setParticipants(prev => { - const updated = prev.map(p => + const updated = prev.map(p => (p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p ); return updated; @@ -483,7 +483,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk } else { // Still mark self as online even when embedded (parent handles socket but we track presence) setParticipants(prev => { - const updated = prev.map(p => + const updated = prev.map(p => (p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p ); return updated; @@ -496,41 +496,41 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (!n) { return; } - + const noteId = n.noteId || n.id; - + // Prevent duplicates: check if message with same noteId already exists setMessages(prev => { if (prev.some(m => m.id === noteId)) { return prev; // Already exists, don't add } - + const userName = n.userName || n.user_name || 'User'; const userRole = n.userRole || n.user_role; // Get role directly from backend const participantRole = getFormattedRole(userRole); const noteUserId = n.userId || n.user_id; - + const newMessage = { id: noteId || String(Date.now()), - user: { - name: userName, - avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), - role: participantRole + user: { + name: userName, + avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), + role: participantRole }, content: n.message || '', timestamp: n.createdAt || new Date().toISOString(), isCurrentUser: noteUserId === currentUserId, - attachments: Array.isArray(n.attachments) ? n.attachments.map((a: any) => ({ + attachments: Array.isArray(n.attachments) ? n.attachments.map((a: any) => ({ attachmentId: a.attachmentId || a.attachment_id, name: a.fileName || a.file_name || a.name, fileName: a.fileName || a.file_name || a.name, - url: a.storageUrl || a.storage_url || a.url || '#', + url: a.storageUrl || a.storage_url || a.url || '#', type: a.fileType || a.file_type || a.type || 'file', fileType: a.fileType || a.file_type || a.type || 'file', fileSize: a.fileSize || a.file_size })) : undefined } as any; - + return [...prev, newMessage]; }); }; @@ -545,7 +545,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (!participant) { return prev; } - const updated = prev.map(p => + const updated = prev.map(p => (p as any).userId === data.userId ? { ...p, status: 'online' as const } : p ); return updated; @@ -558,7 +558,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (data.userId === currentUserId) { return; } - + setParticipants(prev => { if (prev.length === 0) { return prev; @@ -567,7 +567,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (!participant) { return prev; } - const updated = prev.map(p => + const updated = prev.map(p => (p as any).userId === data.userId ? { ...p, status: 'offline' as const } : p ); return updated; @@ -580,17 +580,17 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (prev.length === 0) { return prev; } - + // 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) { return { ...p, status: 'online' as const }; } - + const isOnline = data.userIds.includes(pUserId); return { ...p, status: isOnline ? 'online' as const : 'offline' as const }; }); @@ -603,21 +603,21 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const connectHandler = () => { // Mark self as online on connection setParticipants(prev => { - const updated = prev.map(p => + const updated = prev.map(p => (p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p ); return updated; }); - + // Rejoin room if needed if (!skipSocketJoin) { joinRequestRoom(s, joinedId, currentUserId); } - + // Request online users on connection with multiple retries if (participantsLoadedRef.current) { s.emit('request:online-users', { requestId: joinedId }); - + // Send additional requests with delay to ensure we get the response setTimeout(() => s.emit('request:online-users', { requestId: joinedId }), 300); setTimeout(() => s.emit('request:online-users', { requestId: joinedId }), 800); @@ -628,15 +628,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const errorHandler = (error: any) => { console.error('[WorkNoteChat] โŒ Socket error:', error); }; - + const disconnectHandler = (reason: string) => { console.warn('[WorkNoteChat] โš ๏ธ Socket disconnected:', reason); // Mark all other users as offline on disconnect - setParticipants(prev => prev.map(p => + setParticipants(prev => prev.map(p => (p as any).userId === currentUserId ? p : { ...p, status: 'offline' as const } )); }; - + // Debug: Log ALL events received from server for this request const anyEventHandler = (eventName: string) => { if (eventName.includes('presence') || eventName.includes('worknote') || eventName.includes('request')) { @@ -657,7 +657,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk // Store socket in ref for coordination with participants loading socketRef.current = s; - + // Always request online users after socket is ready if (s.connected) { if (participantsLoadedRef.current) { @@ -693,10 +693,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk // Socket cleanup completed - logging removed }; (window as any).__wn_cleanup = cleanup; - } catch {} + } catch { } })(); return () => { - try { (window as any).__wn_cleanup?.(); } catch {} + try { (window as any).__wn_cleanup?.(); } catch { } }; }, [effectiveRequestId, currentUserId, skipSocketJoin]); @@ -704,11 +704,11 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (message.trim() || selectedFiles.length > 0) { // Extract mentions from message const mentions = extractMentions(message); - + // Find mentioned user IDs from participants const mentionedUserIds = mentions .map(mentionedName => { - const participant = participants.find(p => + const participant = participants.find(p => p.name.toLowerCase().includes(mentionedName.toLowerCase()) ); return (participant as any)?.userId; @@ -727,20 +727,20 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk id: Date.now().toString(), user: { name: 'You', avatar: 'YO', role: 'Current User' }, content: message, - timestamp: new Date().toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', + timestamp: new Date().toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', minute: 'numeric', - hour12: true + hour12: true }), mentions: mentions, isHighPriority: message.includes('!important') || message.includes('urgent'), attachments: attachments.length > 0 ? attachments : undefined, isCurrentUser: true }; - + // If external onSend provided, delegate to caller (RequestDetail will POST and refresh) if (onSend) { try { await onSend(message, selectedFiles); } catch { /* ignore */ } @@ -748,39 +748,49 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk // Fallback: call backend directly with mentions try { await createWorkNoteMultipart( - effectiveRequestId, - { + effectiveRequestId, + { message, mentions: mentionedUserIds // Send mentioned user IDs to backend - }, + }, selectedFiles ); const rows = await getWorkNotes(effectiveRequestId); const mapped = Array.isArray(rows) ? rows.map((m: any) => { const noteUserId = m.userId || m.user_id; return { - id: m.noteId || m.id || String(Math.random()), - user: { - name: m.userName || 'User', - avatar: (m.userName || 'U').slice(0,2).toUpperCase(), - role: m.userRole || 'Participant' + id: m.noteId || m.id || String(Math.random()), + user: { + name: m.userName || 'User', + avatar: (m.userName || 'U').slice(0, 2).toUpperCase(), + role: m.userRole || 'Participant' }, - content: m.message || '', - timestamp: m.createdAt || new Date().toISOString(), + content: m.message || '', + timestamp: m.createdAt || new Date().toISOString(), isCurrentUser: noteUserId === currentUserId, - attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ + attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ attachmentId: a.attachmentId || a.attachment_id, name: a.fileName || a.file_name || a.name, fileName: a.fileName || a.file_name || a.name, - url: a.storageUrl || a.storage_url || a.url || '#', + url: a.storageUrl || a.storage_url || a.url || '#', type: a.fileType || a.file_type || a.type || 'file', fileType: a.fileType || a.file_type || a.type || 'file', fileSize: a.fileSize || a.file_size })) : undefined }; }) : []; - setMessages(mapped as any); - } catch { + setMessages(prev => { + // Keep system messages (activities) from the previous state + const systemMessages = prev.filter(m => m.isSystem); + // Combine with the newly fetched work notes + const combined = [...mapped, ...systemMessages]; + // Sort to maintain chronological order + return combined.sort((a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ) as any; + }); + } catch (error) { + console.error('[WorkNoteChat] Failed to send message or fetch notes:', error); setMessages(prev => [...prev, newMessage]); } } @@ -800,55 +810,55 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk return activityType !== 'sla_warning'; }) .map((m: any) => { - // Check if this is an activity (system message) or work note - const isActivity = m.type || m.activityType || m.isSystem; - - if (isActivity) { - // Map activity to system message - return { - id: m.id || `activity-${m.timestamp || Date.now()}-${Math.random()}`, - user: { name: 'System', avatar: 'SY', role: 'System' }, - content: m.details || m.action || m.content || '', - timestamp: m.timestamp || m.createdAt || m.created_at || new Date().toISOString(), - isSystem: true, - isCurrentUser: false - }; - } else { - // Map work note - const userName = m.userName || m.user_name || m.user?.name || 'User'; - const userRole = m.userRole || m.user_role; - const participantRole = getFormattedRole(userRole); - const noteUserId = m.userId || m.user_id; - - return { - id: m.noteId || m.note_id || m.id || String(Math.random()), - user: { - name: userName, - avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), - role: participantRole - }, - content: m.message || m.content || '', - timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(), - isSystem: false, - attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ - attachmentId: a.attachmentId || a.attachment_id, - name: a.fileName || a.file_name || a.name, - fileName: a.fileName || a.file_name || a.name, - url: a.storageUrl || a.storage_url || a.url || '#', - type: a.fileType || a.file_type || a.type || 'file', - fileType: a.fileType || a.file_type || a.type || 'file', - fileSize: a.fileSize || a.file_size - })) : undefined, - isCurrentUser: noteUserId === currentUserId - }; - } - }); - + // Check if this is an activity (system message) or work note + const isActivity = m.type || m.activityType || m.isSystem; + + if (isActivity) { + // Map activity to system message + return { + id: m.id || `activity-${m.timestamp || Date.now()}-${Math.random()}`, + user: { name: 'System', avatar: 'SY', role: 'System' }, + content: m.details || m.action || m.content || '', + timestamp: m.timestamp || m.createdAt || m.created_at || new Date().toISOString(), + isSystem: true, + isCurrentUser: false + }; + } else { + // Map work note + const userName = m.userName || m.user_name || m.user?.name || 'User'; + const userRole = m.userRole || m.user_role; + const participantRole = getFormattedRole(userRole); + const noteUserId = m.userId || m.user_id; + + return { + id: m.noteId || m.note_id || m.id || String(Math.random()), + user: { + name: userName, + avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), + role: participantRole + }, + content: m.message || m.content || '', + timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(), + isSystem: false, + attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ + attachmentId: a.attachmentId || a.attachment_id, + name: a.fileName || a.file_name || a.name, + fileName: a.fileName || a.file_name || a.name, + url: a.storageUrl || a.storage_url || a.url || '#', + type: a.fileType || a.file_type || a.type || 'file', + fileType: a.fileType || a.file_type || a.type || 'file', + fileSize: a.fileSize || a.file_size + })) : undefined, + isCurrentUser: noteUserId === currentUserId + }; + } + }); + // Sort by timestamp - const sorted = mapped.sort((a, b) => + const sorted = mapped.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); - + // Messages mapped - logging removed setMessages(sorted); } catch (err) { @@ -859,26 +869,26 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk (async () => { try { const rows = await getWorkNotes(effectiveRequestId); - const mapped = Array.isArray(rows) ? rows.map((m: any) => { - const userName = m.userName || m.user_name || 'User'; - const userRole = m.userRole || m.user_role; // Get role directly from backend - const participantRole = getFormattedRole(userRole); - const noteUserId = m.userId || m.user_id; - - return { + const mapped = Array.isArray(rows) ? rows.map((m: any) => { + const userName = m.userName || m.user_name || 'User'; + const userRole = m.userRole || m.user_role; // Get role directly from backend + const participantRole = getFormattedRole(userRole); + const noteUserId = m.userId || m.user_id; + + return { id: m.noteId || m.note_id || m.id || String(Math.random()), - user: { - name: userName, - avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), - role: participantRole + user: { + name: userName, + avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), + role: participantRole }, content: m.message || '', timestamp: m.createdAt || m.created_at || new Date().toISOString(), - attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ + attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ attachmentId: a.attachmentId || a.attachment_id, name: a.fileName || a.file_name || a.name, fileName: a.fileName || a.file_name || a.name, - url: a.storageUrl || a.storage_url || a.url || '#', + url: a.storageUrl || a.storage_url || a.url || '#', type: a.fileType || a.file_type || a.type || 'file', fileType: a.fileType || a.file_type || a.type || 'file', fileSize: a.fileSize || a.file_size @@ -907,7 +917,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk // Check file extension const fileName = file.name.toLowerCase(); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); - + if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { return { valid: false, @@ -920,9 +930,9 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const handleFileSelect = (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; - + const filesArray = Array.from(e.target.files); - + // Validate all files const validationErrors: Array<{ fileName: string; reason: string }> = []; const validFiles: File[] = []; @@ -994,7 +1004,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const userName = p.userName || p.user_name || p.userEmail || p.user_email || 'User'; const userEmail = p.userEmail || p.user_email || ''; const initials = userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(); - + return { name: userName, avatar: initials, @@ -1007,7 +1017,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk }; }); setParticipants(mapped); - + // Request updated online users list from server to get correct status if (socketRef.current && socketRef.current.connected) { socketRef.current.emit('request:online-users', { requestId: effectiveRequestId }); @@ -1054,7 +1064,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const userName = p.userName || p.user_name || p.userEmail || p.user_email || 'User'; const userEmail = p.userEmail || p.user_email || ''; const initials = userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(); - + return { name: userName, avatar: initials, @@ -1067,7 +1077,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk }; }); setParticipants(mapped); - + if (socketRef.current && socketRef.current.connected) { socketRef.current.emit('request:online-users', { requestId: effectiveRequestId }); } @@ -1105,35 +1115,35 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk '๐Ÿ˜ณ', '๐Ÿฅบ', '๐Ÿ˜ฆ', '๐Ÿ˜ง', '๐Ÿ˜จ', '๐Ÿ˜ฐ', '๐Ÿ˜ฅ', '๐Ÿ˜ข', '๐Ÿ˜ญ', '๐Ÿ˜ฑ', '๐Ÿ˜–', '๐Ÿ˜ฃ', '๐Ÿ˜ž', '๐Ÿ˜“', '๐Ÿ˜ฉ', '๐Ÿ˜ซ', '๐Ÿฅฑ', '๐Ÿ˜ค', '๐Ÿ˜ก', '๐Ÿ˜ ', '๐Ÿคฌ', '๐Ÿ˜ˆ', '๐Ÿ‘ฟ', '๐Ÿ’€', 'โ˜ ๏ธ', '๐Ÿ’ฉ', '๐Ÿคก', '๐Ÿ‘น', '๐Ÿ‘บ', '๐Ÿ‘ป', - + // Gestures & Body '๐Ÿ‘‹', '๐Ÿคš', '๐Ÿ–๏ธ', 'โœ‹', '๐Ÿ––', '๐Ÿ‘Œ', '๐ŸคŒ', '๐Ÿค', 'โœŒ๏ธ', '๐Ÿคž', '๐ŸคŸ', '๐Ÿค˜', '๐Ÿค™', '๐Ÿ‘ˆ', '๐Ÿ‘‰', '๐Ÿ‘†', '๐Ÿ–•', '๐Ÿ‘‡', 'โ˜๏ธ', '๐Ÿ‘', '๐Ÿ‘Ž', 'โœŠ', '๐Ÿ‘Š', '๐Ÿค›', '๐Ÿคœ', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿ‘', '๐Ÿคฒ', '๐Ÿค', '๐Ÿ™', '๐Ÿ’ช', '๐Ÿฆพ', '๐Ÿฆฟ', '๐Ÿฆต', '๐Ÿฆถ', '๐Ÿ‘‚', '๐Ÿฆป', '๐Ÿ‘ƒ', '๐Ÿง ', - + // Hearts & Love 'โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿค', '๐ŸคŽ', '๐Ÿ’”', 'โฃ๏ธ', '๐Ÿ’•', '๐Ÿ’ž', '๐Ÿ’“', '๐Ÿ’—', '๐Ÿ’–', '๐Ÿ’˜', '๐Ÿ’', '๐Ÿ’Ÿ', 'โค๏ธโ€๐Ÿ”ฅ', - + // Work & Office '๐Ÿ’ผ', '๐Ÿ“Š', '๐Ÿ“ˆ', '๐Ÿ“‰', '๐Ÿ’ป', 'โŒจ๏ธ', '๐Ÿ–ฅ๏ธ', '๐Ÿ–จ๏ธ', '๐Ÿ–ฑ๏ธ', '๐Ÿ’พ', '๐Ÿ’ฟ', '๐Ÿ“ฑ', 'โ˜Ž๏ธ', '๐Ÿ“ž', '๐Ÿ“Ÿ', '๐Ÿ“ ', '๐Ÿ“ง', 'โœ‰๏ธ', '๐Ÿ“จ', '๐Ÿ“ฉ', '๐Ÿ“ฎ', '๐Ÿ“ช', '๐Ÿ“ซ', '๐Ÿ“ฌ', '๐Ÿ“ญ', '๐Ÿ“„', '๐Ÿ“ƒ', '๐Ÿ“‘', '๐Ÿ“', 'โœ๏ธ', 'โœ’๏ธ', '๐Ÿ–Š๏ธ', '๐Ÿ–‹๏ธ', '๐Ÿ“', '๐Ÿ“', '๐Ÿ“Œ', '๐Ÿ“', '๐Ÿ—‚๏ธ', '๐Ÿ“', '๐Ÿ“‚', - + // Success & Achievement 'โœ…', 'โœ”๏ธ', 'โ˜‘๏ธ', '๐ŸŽฏ', '๐ŸŽ–๏ธ', '๐Ÿ†', '๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰', 'โญ', '๐ŸŒŸ', 'โœจ', '๐Ÿ’ซ', '๐Ÿ”ฅ', '๐Ÿ’ฅ', 'โšก', '๐Ÿ’ฏ', '๐ŸŽ‰', '๐ŸŽŠ', '๐ŸŽˆ', - + // Alerts & Symbols 'โš ๏ธ', '๐Ÿšซ', 'โŒ', 'โ›”', '๐Ÿšท', '๐Ÿšฏ', '๐Ÿšฑ', '๐Ÿšณ', '๐Ÿ”ž', '๐Ÿ“ต', 'โ—', 'โ“', 'โ•', 'โ”', 'โ€ผ๏ธ', 'โ‰๏ธ', '๐Ÿ’ข', '๐Ÿ’ฌ', '๐Ÿ’ญ', '๐Ÿ—ฏ๏ธ', - + // Time & Calendar 'โฐ', 'โฑ๏ธ', 'โฒ๏ธ', 'โณ', 'โŒ›', '๐Ÿ“…', '๐Ÿ“†', '๐Ÿ—“๏ธ', '๐Ÿ“‡', '๐Ÿ•', '๐Ÿ•‘', '๐Ÿ•’', '๐Ÿ•“', '๐Ÿ•”', '๐Ÿ••', '๐Ÿ•–', '๐Ÿ•—', '๐Ÿ•˜', '๐Ÿ•™', '๐Ÿ•š', - + // Actions & Arrows '๐Ÿš€', '๐ŸŽฏ', '๐ŸŽฒ', '๐ŸŽฐ', '๐Ÿงฉ', '๐Ÿ”', '๐Ÿ”Ž', '๐Ÿ”‘', '๐Ÿ—๏ธ', '๐Ÿ”’', '๐Ÿ”“', '๐Ÿ”', '๐Ÿ”', '๐Ÿ””', '๐Ÿ”•', '๐Ÿ“ฃ', '๐Ÿ“ข', '๐Ÿ’ก', '๐Ÿ”ฆ', '๐Ÿฎ', @@ -1155,7 +1165,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk 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()); @@ -1177,7 +1187,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk if (msg.id === messageId) { const reactions = msg.reactions || []; const existingReaction = reactions.find(r => r.emoji === emoji); - + if (existingReaction) { if (existingReaction.users.includes('You')) { existingReaction.users = existingReaction.users.filter(u => u !== 'You'); @@ -1190,7 +1200,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk } else { reactions.push({ emoji, users: ['You'] }); } - + return { ...msg, reactions }; } return msg; @@ -1216,7 +1226,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
- +
@@ -1235,8 +1245,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
- - )} - + + )} + {/* Download button */} -
- ); - })} - + ); + })} - )} + + )} - {/* Reactions */} - {msg.reactions && msg.reactions.length > 0 && ( -
- {msg.reactions.map((reaction, index) => ( - - ))} - -
- )} - - - )} - - - {!msg.isSystem && isCurrentUser && ( - - - {msg.user.avatar} - - - )} - - ); - })} - -
-
- - - {/* Message Input - Fixed at bottom */} -
-
- {/* Hidden File Input */} - `.${ext}`).join(',')} - /> - - {/* Selected Files Preview - Scrollable if many files */} - {selectedFiles.length > 0 && ( -
- {selectedFiles.map((file, index) => ( -
-
- -
- {file.name} - {(file.size / 1024).toFixed(1)} KB - -
- ))} -
- )} - - {/* Textarea with Mention Dropdown and Emoji Picker */} -
- {/* 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; - - 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 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 && - !containsSpaceInMiddle && - !isCompletedMention; - - if (!shouldShowDropdown) return null; - - // 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; - if (isCurrentUserInList) return false; - - // Filter by search term (empty search term shows all) - if (searchTerm) { - return p.name.toLowerCase().includes(searchTerm); - } - return true; // Show all if no search term - }); - - return ( -
-

๐Ÿ’ฌ Mention someone

-
- {filteredParticipants.length > 0 ? ( - filteredParticipants.map((participant, idx) => ( - + ))} + - )) - ) : ( -

- {searchTerm ? `No participants found matching "${searchTerm}"` : 'No other participants available'} -

+ + +
)}
- ); - })()} + )} +
-