// noqa: js-006 - async init pattern is safe, loadData has try/catch /** * Admin Messages Page * * Handles the messaging interface including: * - Conversation list with filtering and pagination * - Message thread display * - Sending messages with attachments * - Creating new conversations */ const messagesLog = window.LogConfig?.createLogger('MESSAGES') || console; /** * Admin Messages Component */ function adminMessages(initialConversationId = null) { return { ...data(), currentPage: 'messages', // Loading states loading: true, error: null, loadingConversations: false, loadingMessages: false, sendingMessage: false, creatingConversation: false, // Conversations state conversations: [], page: 1, skip: 0, limit: 20, totalConversations: 0, totalUnread: 0, // Filters filters: { conversation_type: '', is_closed: '' }, // Selected conversation selectedConversationId: initialConversationId, selectedConversation: null, // Reply form replyContent: '', attachedFiles: [], // Compose modal showComposeModal: false, compose: { recipientType: '', recipientId: null, subject: '', message: '' }, recipients: [], loadingRecipients: false, // Polling pollInterval: null, /** * Initialize component */ async init() { // Guard against multiple initialization if (window._adminMessagesInitialized) return; window._adminMessagesInitialized = true; try { messagesLog.debug('Initializing messages page'); await this.loadConversations(); if (this.selectedConversationId) { await this.loadConversation(this.selectedConversationId); } // Start polling for new messages this.startPolling(); } catch (error) { messagesLog.error('Failed to initialize messages page:', error); } finally { this.loading = false; } }, /** * Start polling for updates */ startPolling() { this.pollInterval = setInterval(async () => { // Only poll if we have a selected conversation if (this.selectedConversationId && !document.hidden) { await this.refreshCurrentConversation(); } // Update unread count await this.updateUnreadCount(); }, 30000); // 30 seconds }, /** * Stop polling */ destroy() { if (this.pollInterval) { clearInterval(this.pollInterval); } }, // ============================================================================ // CONVERSATIONS LIST // ============================================================================ /** * Load conversations with current filters */ async loadConversations() { this.loadingConversations = true; try { this.skip = (this.page - 1) * this.limit; const params = new URLSearchParams(); params.append('skip', this.skip); params.append('limit', this.limit); if (this.filters.conversation_type) { params.append('conversation_type', this.filters.conversation_type); } if (this.filters.is_closed !== '') { params.append('is_closed', this.filters.is_closed); } const response = await apiClient.get(`/admin/messages?${params}`); this.conversations = response.conversations || []; this.totalConversations = response.total || 0; this.totalUnread = response.total_unread || 0; messagesLog.debug(`Loaded ${this.conversations.length} conversations`); } catch (error) { messagesLog.error('Failed to load conversations:', error); Utils.showToast('Failed to load conversations', 'error'); } finally { this.loadingConversations = false; } }, /** * Update unread count */ async updateUnreadCount() { try { const response = await apiClient.get('/admin/messages/unread-count'); this.totalUnread = response.total_unread || 0; } catch (error) { messagesLog.error('Failed to update unread count:', error); } }, /** * Select a conversation */ async selectConversation(conversationId) { if (this.selectedConversationId === conversationId) return; this.selectedConversationId = conversationId; await this.loadConversation(conversationId); // Update URL without reload const url = new URL(window.location); url.pathname = `/admin/messages/${conversationId}`; window.history.pushState({}, '', url); }, /** * Load conversation detail */ async loadConversation(conversationId) { this.loadingMessages = true; try { const response = await apiClient.get(`/admin/messages/${conversationId}?mark_read=true`); this.selectedConversation = response; // Update unread count in list const conv = this.conversations.find(c => c.id === conversationId); if (conv) { this.totalUnread = Math.max(0, this.totalUnread - conv.unread_count); conv.unread_count = 0; } messagesLog.debug(`Loaded conversation ${conversationId} with ${response.messages?.length || 0} messages`); // Scroll to bottom await this.$nextTick(); this.scrollToBottom(); } catch (error) { messagesLog.error('Failed to load conversation:', error); Utils.showToast('Failed to load conversation', 'error'); } finally { this.loadingMessages = false; } }, /** * Refresh current conversation */ async refreshCurrentConversation() { if (!this.selectedConversationId) return; try { const response = await apiClient.get(`/admin/messages/${this.selectedConversationId}?mark_read=true`); const oldCount = this.selectedConversation?.messages?.length || 0; const newCount = response.messages?.length || 0; this.selectedConversation = response; // If new messages, scroll to bottom if (newCount > oldCount) { await this.$nextTick(); this.scrollToBottom(); } } catch (error) { messagesLog.error('Failed to refresh conversation:', error); } }, /** * Scroll messages to bottom */ scrollToBottom() { const container = this.$refs.messagesContainer; if (container) { container.scrollTop = container.scrollHeight; } }, // ============================================================================ // SENDING MESSAGES // ============================================================================ /** * Handle file selection */ handleFileSelect(event) { const files = Array.from(event.target.files); this.attachedFiles = files; messagesLog.debug(`Selected ${files.length} files`); }, /** * Send a message */ async sendMessage() { if (!this.replyContent.trim() && this.attachedFiles.length === 0) return; if (!this.selectedConversationId) return; this.sendingMessage = true; try { const formData = new FormData(); formData.append('content', this.replyContent); for (const file of this.attachedFiles) { formData.append('files', file); } const message = await apiClient.postFormData(`/admin/messages/${this.selectedConversationId}/messages`, formData); // Add to messages if (this.selectedConversation) { this.selectedConversation.messages.push(message); this.selectedConversation.message_count++; } // Clear form this.replyContent = ''; this.attachedFiles = []; if (this.$refs.fileInput) { this.$refs.fileInput.value = ''; } // Scroll to bottom await this.$nextTick(); this.scrollToBottom(); messagesLog.debug(`Sent message ${message.id}`); } catch (error) { messagesLog.error('Failed to send message:', error); Utils.showToast(error.message || 'Failed to send message', 'error'); } finally { this.sendingMessage = false; } }, // ============================================================================ // CONVERSATION ACTIONS // ============================================================================ /** * Close conversation */ async closeConversation() { if (!confirm('Are you sure you want to close this conversation?')) return; try { await apiClient.post(`/admin/messages/${this.selectedConversationId}/close`); if (this.selectedConversation) { this.selectedConversation.is_closed = true; } // Update in list const conv = this.conversations.find(c => c.id === this.selectedConversationId); if (conv) { conv.is_closed = true; } Utils.showToast('Conversation closed', 'success'); } catch (error) { messagesLog.error('Failed to close conversation:', error); Utils.showToast('Failed to close conversation', 'error'); } }, /** * Reopen conversation */ async reopenConversation() { try { await apiClient.post(`/admin/messages/${this.selectedConversationId}/reopen`); if (this.selectedConversation) { this.selectedConversation.is_closed = false; } // Update in list const conv = this.conversations.find(c => c.id === this.selectedConversationId); if (conv) { conv.is_closed = false; } Utils.showToast('Conversation reopened', 'success'); } catch (error) { messagesLog.error('Failed to reopen conversation:', error); Utils.showToast('Failed to reopen conversation', 'error'); } }, // ============================================================================ // CREATE CONVERSATION // ============================================================================ /** * Load recipients for compose modal */ async loadRecipients() { if (!this.compose.recipientType) { this.recipients = []; return; } this.loadingRecipients = true; try { const response = await apiClient.get(`/admin/messages/recipients?recipient_type=${this.compose.recipientType}&limit=100`); this.recipients = response.recipients || []; messagesLog.debug(`Loaded ${this.recipients.length} recipients`); } catch (error) { messagesLog.error('Failed to load recipients:', error); Utils.showToast('Failed to load recipients', 'error'); } finally { this.loadingRecipients = false; } }, /** * Create new conversation */ async createConversation() { if (!this.compose.recipientId || !this.compose.subject.trim()) return; this.creatingConversation = true; try { // Determine conversation type const conversationType = this.compose.recipientType === 'vendor' ? 'admin_vendor' : 'admin_customer'; // Get vendor_id if customer let vendorId = null; if (this.compose.recipientType === 'customer') { const recipient = this.recipients.find(r => r.id === parseInt(this.compose.recipientId)); vendorId = recipient?.vendor_id; } const response = await apiClient.post('/admin/messages', { conversation_type: conversationType, subject: this.compose.subject, recipient_type: this.compose.recipientType, recipient_id: parseInt(this.compose.recipientId), vendor_id: vendorId, initial_message: this.compose.message || null }); // Close modal and reset this.showComposeModal = false; this.compose = { recipientType: '', recipientId: null, subject: '', message: '' }; this.recipients = []; // Reload conversations and select new one await this.loadConversations(); await this.selectConversation(response.id); Utils.showToast('Conversation created', 'success'); } catch (error) { messagesLog.error('Failed to create conversation:', error); Utils.showToast(error.message || 'Failed to create conversation', 'error'); } finally { this.creatingConversation = false; } }, // ============================================================================ // HELPERS // ============================================================================ /** * Get other participant name */ getOtherParticipantName() { if (!this.selectedConversation?.participants) return 'Unknown'; const other = this.selectedConversation.participants.find( p => p.participant_type !== 'admin' ); return other?.participant_info?.name || 'Unknown'; }, /** * Format relative time */ formatRelativeTime(dateString) { if (!dateString) return ''; const date = new Date(dateString); const now = new Date(); const diff = Math.floor((now - date) / 1000); if (diff < 60) return 'Now'; if (diff < 3600) return `${Math.floor(diff / 60)}m`; if (diff < 86400) return `${Math.floor(diff / 3600)}h`; if (diff < 172800) return 'Yesterday'; if (diff < 604800) return `${Math.floor(diff / 86400)}d`; return date.toLocaleDateString(); }, /** * Format time for messages */ formatTime(dateString) { if (!dateString) return ''; const date = new Date(dateString); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); if (isToday) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } }; } // Make available globally window.adminMessages = adminMessages;