feat: add admin messaging interface
- Add admin messages API endpoints (/api/v1/admin/messages) - Add admin messages page route (/admin/messages) - Add messages.html template with split-panel conversation view - Add messages.js Alpine component for messaging UI - Add Messages link to admin sidebar under Platform Administration - Add message icon with unread badge to admin header - Update init-alpine.js with headerMessages component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ function data() {
|
||||
companies: 'platformAdmin',
|
||||
vendors: 'platformAdmin',
|
||||
users: 'platformAdmin',
|
||||
messages: 'platformAdmin',
|
||||
// Vendor Operations (Products, Customers, Inventory, Orders, Shipping)
|
||||
'marketplace-products': 'vendorOps',
|
||||
'vendor-products': 'vendorOps',
|
||||
@@ -221,4 +222,39 @@ function languageSelector(currentLang, enabledLanguages) {
|
||||
}
|
||||
|
||||
// Export to window for use in templates
|
||||
window.languageSelector = languageSelector;
|
||||
window.languageSelector = languageSelector;
|
||||
|
||||
/**
|
||||
* Header messages badge component
|
||||
* Shows unread message count in header
|
||||
*/
|
||||
function headerMessages() {
|
||||
return {
|
||||
unreadCount: 0,
|
||||
pollInterval: null,
|
||||
|
||||
async init() {
|
||||
await this.fetchUnreadCount();
|
||||
// Poll every 30 seconds
|
||||
this.pollInterval = setInterval(() => this.fetchUnreadCount(), 30000);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUnreadCount() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/messages/unread-count');
|
||||
this.unreadCount = response.total_unread || 0;
|
||||
} catch (error) {
|
||||
// Silently fail - don't spam console for auth issues on login page
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Export to window
|
||||
window.headerMessages = headerMessages;
|
||||
489
static/admin/js/messages.js
Normal file
489
static/admin/js/messages.js
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 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 {
|
||||
// Loading states
|
||||
loading: true,
|
||||
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() {
|
||||
messagesLog.debug('Initializing messages page');
|
||||
await this.loadConversations();
|
||||
|
||||
if (this.selectedConversationId) {
|
||||
await this.loadConversation(this.selectedConversationId);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
|
||||
// Start polling for new messages
|
||||
this.startPolling();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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);
|
||||
window.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);
|
||||
window.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 response = await fetch(`/api/v1/admin/messages/${this.selectedConversationId}/messages`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.getAuthToken?.() || ''}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to send message');
|
||||
}
|
||||
|
||||
const message = await response.json();
|
||||
|
||||
// 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);
|
||||
window.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;
|
||||
}
|
||||
|
||||
window.showToast?.('Conversation closed', 'success');
|
||||
} catch (error) {
|
||||
messagesLog.error('Failed to close conversation:', error);
|
||||
window.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;
|
||||
}
|
||||
|
||||
window.showToast?.('Conversation reopened', 'success');
|
||||
} catch (error) {
|
||||
messagesLog.error('Failed to reopen conversation:', error);
|
||||
window.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);
|
||||
window.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);
|
||||
|
||||
window.showToast?.('Conversation created', 'success');
|
||||
} catch (error) {
|
||||
messagesLog.error('Failed to create conversation:', error);
|
||||
window.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;
|
||||
Reference in New Issue
Block a user