refactor(js): migrate JavaScript files to module directories
Move 47 JS files from static/{admin,vendor,shared}/js/ to their
respective module directories app/modules/*/static/*/js/:
- Orders: orders.js, order-detail.js
- Catalog: products.js (renamed from vendor-products.js), product-*.js
- Inventory: inventory.js (admin & vendor)
- Customers: customers.js, users.js, user-*.js
- Billing: billing-history.js, subscriptions.js, subscription-tiers.js,
billing.js, invoices.js, feature-store.js, upgrade-prompts.js
- Messaging: messages.js, notifications.js, email-templates.js
- Marketplace: marketplace*.js, letzshop*.js, onboarding.js
- Monitoring: monitoring.js, background-tasks.js, imports.js, logs.js
- Dev Tools: testing-*.js, code-quality-*.js
Update 39 templates to reference new module static paths using
url_for('{module}_static', path='...') pattern.
Files staying in static/ (platform core):
- admin: dashboard, login, platforms, vendors, companies, admin-users,
settings, components, init-alpine, module-config
- vendor: dashboard, login, profile, settings, team, media, init-alpine
- shared: api-client, utils, money, icons, log-config, vendor-selector,
media-picker
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
267
app/modules/messaging/static/admin/js/email-templates.js
Normal file
267
app/modules/messaging/static/admin/js/email-templates.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Email Templates Management Page
|
||||
*
|
||||
* Handles:
|
||||
* - Listing all platform email templates
|
||||
* - Editing template content (all languages)
|
||||
* - Preview and test email sending
|
||||
*/
|
||||
|
||||
const emailTemplatesLog = window.LogConfig?.createLogger?.('emailTemplates') ||
|
||||
{ info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||
|
||||
function emailTemplatesPage() {
|
||||
return {
|
||||
// Data
|
||||
loading: true,
|
||||
templates: [],
|
||||
categories: [],
|
||||
selectedCategory: null,
|
||||
|
||||
// Edit Modal
|
||||
showEditModal: false,
|
||||
editingTemplate: null,
|
||||
editLanguage: 'en',
|
||||
loadingTemplate: false,
|
||||
editForm: {
|
||||
subject: '',
|
||||
body_html: '',
|
||||
body_text: '',
|
||||
variables: [],
|
||||
required_variables: []
|
||||
},
|
||||
saving: false,
|
||||
|
||||
// Preview Modal
|
||||
showPreviewModal: false,
|
||||
previewData: null,
|
||||
|
||||
// Test Email Modal
|
||||
showTestEmailModal: false,
|
||||
testEmailAddress: '',
|
||||
sendingTest: false,
|
||||
|
||||
// Computed
|
||||
get filteredTemplates() {
|
||||
if (!this.selectedCategory) {
|
||||
return this.templates;
|
||||
}
|
||||
return this.templates.filter(t => t.category === this.selectedCategory);
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
if (window._adminEmailTemplatesInitialized) return;
|
||||
window._adminEmailTemplatesInitialized = true;
|
||||
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
// Data Loading
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [templatesData, categoriesData] = await Promise.all([
|
||||
apiClient.get('/admin/email-templates'),
|
||||
apiClient.get('/admin/email-templates/categories')
|
||||
]);
|
||||
|
||||
this.templates = templatesData.templates || [];
|
||||
this.categories = categoriesData.categories || [];
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to load email templates:', error);
|
||||
Utils.showToast('Failed to load templates', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Category styling
|
||||
getCategoryClass(category) {
|
||||
const classes = {
|
||||
'auth': 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
|
||||
'orders': 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
||||
'billing': 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
|
||||
'system': 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
|
||||
'marketing': 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200'
|
||||
};
|
||||
return classes[category] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200';
|
||||
},
|
||||
|
||||
// Edit Template
|
||||
async editTemplate(template) {
|
||||
this.editingTemplate = template;
|
||||
this.editLanguage = 'en';
|
||||
this.showEditModal = true;
|
||||
await this.loadTemplateLanguage();
|
||||
},
|
||||
|
||||
async loadTemplateLanguage() {
|
||||
if (!this.editingTemplate) return;
|
||||
|
||||
this.loadingTemplate = true;
|
||||
try {
|
||||
const data = await apiClient.get(
|
||||
`/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
|
||||
);
|
||||
|
||||
this.editForm = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
variables: data.variables || [],
|
||||
required_variables: data.required_variables || []
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
// Template doesn't exist for this language yet
|
||||
this.editForm = {
|
||||
subject: '',
|
||||
body_html: '',
|
||||
body_text: '',
|
||||
variables: [],
|
||||
required_variables: []
|
||||
};
|
||||
Utils.showToast(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
|
||||
} else {
|
||||
emailTemplatesLog.error('Failed to load template:', error);
|
||||
Utils.showToast('Failed to load template', 'error');
|
||||
}
|
||||
} finally {
|
||||
this.loadingTemplate = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeEditModal() {
|
||||
this.showEditModal = false;
|
||||
this.editingTemplate = null;
|
||||
this.editForm = {
|
||||
subject: '',
|
||||
body_html: '',
|
||||
body_text: '',
|
||||
variables: [],
|
||||
required_variables: []
|
||||
};
|
||||
},
|
||||
|
||||
async saveTemplate() {
|
||||
if (!this.editingTemplate) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
{
|
||||
subject: this.editForm.subject,
|
||||
body_html: this.editForm.body_html,
|
||||
body_text: this.editForm.body_text
|
||||
}
|
||||
);
|
||||
|
||||
Utils.showToast('Template saved successfully', 'success');
|
||||
// Refresh templates list
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to save template:', error);
|
||||
Utils.showToast(error.detail || 'Failed to save template', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Preview
|
||||
async previewTemplate(template) {
|
||||
try {
|
||||
// Use sample variables for preview
|
||||
const sampleVariables = this.getSampleVariables(template.code);
|
||||
|
||||
const data = await apiClient.post(
|
||||
`/admin/email-templates/${template.code}/preview`,
|
||||
{
|
||||
template_code: template.code,
|
||||
language: 'en',
|
||||
variables: sampleVariables
|
||||
}
|
||||
);
|
||||
|
||||
this.previewData = data;
|
||||
this.showPreviewModal = true;
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to preview template:', error);
|
||||
Utils.showToast('Failed to load preview', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
getSampleVariables(templateCode) {
|
||||
// Sample variables for common templates
|
||||
const samples = {
|
||||
'signup_welcome': {
|
||||
first_name: 'John',
|
||||
company_name: 'Acme Corp',
|
||||
email: 'john@example.com',
|
||||
vendor_code: 'acme',
|
||||
login_url: 'https://example.com/login',
|
||||
trial_days: '14',
|
||||
tier_name: 'Business'
|
||||
},
|
||||
'order_confirmation': {
|
||||
customer_name: 'Jane Doe',
|
||||
order_number: 'ORD-12345',
|
||||
order_total: '99.99',
|
||||
order_items_count: '3',
|
||||
order_date: '2024-01-15',
|
||||
shipping_address: '123 Main St, Luxembourg City, L-1234'
|
||||
},
|
||||
'password_reset': {
|
||||
customer_name: 'John Doe',
|
||||
reset_link: 'https://example.com/reset?token=abc123',
|
||||
expiry_hours: '1'
|
||||
},
|
||||
'team_invite': {
|
||||
invitee_name: 'Jane',
|
||||
inviter_name: 'John',
|
||||
vendor_name: 'Acme Corp',
|
||||
role: 'Admin',
|
||||
accept_url: 'https://example.com/accept',
|
||||
expires_in_days: '7'
|
||||
}
|
||||
};
|
||||
return samples[templateCode] || { platform_name: 'Wizamart' };
|
||||
},
|
||||
|
||||
// Test Email
|
||||
sendTestEmail() {
|
||||
this.showTestEmailModal = true;
|
||||
},
|
||||
|
||||
async confirmSendTestEmail() {
|
||||
if (!this.testEmailAddress || !this.editingTemplate) return;
|
||||
|
||||
this.sendingTest = true;
|
||||
try {
|
||||
const result = await apiClient.post(
|
||||
`/admin/email-templates/${this.editingTemplate.code}/test`,
|
||||
{
|
||||
template_code: this.editingTemplate.code,
|
||||
language: this.editLanguage,
|
||||
to_email: this.testEmailAddress,
|
||||
variables: this.getSampleVariables(this.editingTemplate.code)
|
||||
}
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
Utils.showToast(`Test email sent to ${this.testEmailAddress}`, 'success');
|
||||
this.showTestEmailModal = false;
|
||||
this.testEmailAddress = '';
|
||||
} else {
|
||||
Utils.showToast(result.message || 'Failed to send test email', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
emailTemplatesLog.error('Failed to send test email:', error);
|
||||
Utils.showToast('Failed to send test email', 'error');
|
||||
} finally {
|
||||
this.sendingTest = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
489
app/modules/messaging/static/admin/js/messages.js
Normal file
489
app/modules/messaging/static/admin/js/messages.js
Normal file
@@ -0,0 +1,489 @@
|
||||
// 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;
|
||||
308
app/modules/messaging/static/admin/js/notifications.js
Normal file
308
app/modules/messaging/static/admin/js/notifications.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Admin Notifications Page
|
||||
*
|
||||
* Handles the notifications management interface including:
|
||||
* - Notifications list with filtering and pagination
|
||||
* - Platform alerts management
|
||||
* - Mark as read, delete, and bulk operations
|
||||
*/
|
||||
|
||||
const notificationsLog = window.LogConfig?.createLogger('NOTIFICATIONS') || console;
|
||||
|
||||
/**
|
||||
* Admin Notifications Component
|
||||
*/
|
||||
function adminNotifications() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'notifications',
|
||||
|
||||
// Loading states
|
||||
loading: true,
|
||||
error: null,
|
||||
loadingNotifications: false,
|
||||
loadingAlerts: false,
|
||||
|
||||
// Tab state
|
||||
activeTab: 'notifications',
|
||||
|
||||
// Notifications state
|
||||
notifications: [],
|
||||
page: 1,
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
stats: {
|
||||
total: 0,
|
||||
unread_count: 0
|
||||
},
|
||||
|
||||
// Notifications filters
|
||||
filters: {
|
||||
priority: '',
|
||||
is_read: ''
|
||||
},
|
||||
|
||||
// Alerts state
|
||||
alerts: [],
|
||||
alertPage: 1,
|
||||
alertSkip: 0,
|
||||
alertLimit: 10,
|
||||
alertStats: {
|
||||
total: 0,
|
||||
active_alerts: 0,
|
||||
critical_alerts: 0,
|
||||
resolved_today: 0,
|
||||
by_type: {},
|
||||
by_severity: {}
|
||||
},
|
||||
|
||||
// Alerts filters
|
||||
alertFilters: {
|
||||
severity: '',
|
||||
is_resolved: ''
|
||||
},
|
||||
|
||||
// Resolve modal state
|
||||
showResolveModal: false,
|
||||
resolvingAlert: null,
|
||||
resolutionNotes: '',
|
||||
|
||||
/**
|
||||
* Initialize component
|
||||
*/
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminNotificationsInitialized) return;
|
||||
window._adminNotificationsInitialized = true;
|
||||
|
||||
try {
|
||||
notificationsLog.debug('Initializing notifications page');
|
||||
await Promise.all([
|
||||
this.loadNotifications(),
|
||||
this.loadAlertStats()
|
||||
]);
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to initialize notifications page:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// NOTIFICATIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load notifications with current filters
|
||||
*/
|
||||
async loadNotifications() {
|
||||
this.loadingNotifications = 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.priority) params.append('priority', this.filters.priority);
|
||||
if (this.filters.is_read !== '') params.append('is_read', this.filters.is_read);
|
||||
|
||||
const response = await apiClient.get(`/admin/notifications?${params}`);
|
||||
this.notifications = response.notifications || [];
|
||||
this.stats.total = response.total || 0;
|
||||
this.stats.unread_count = response.unread_count || 0;
|
||||
|
||||
notificationsLog.debug(`Loaded ${this.notifications.length} notifications`);
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to load notifications:', error);
|
||||
Utils.showToast('Failed to load notifications', 'error');
|
||||
} finally {
|
||||
this.loadingNotifications = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
async markAsRead(notification) {
|
||||
try {
|
||||
await apiClient.put(`/admin/notifications/${notification.id}/read`);
|
||||
|
||||
// Update local state
|
||||
notification.is_read = true;
|
||||
this.stats.unread_count = Math.max(0, this.stats.unread_count - 1);
|
||||
|
||||
Utils.showToast('Notification marked as read', 'success');
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to mark as read:', error);
|
||||
Utils.showToast('Failed to mark notification as read', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
async markAllAsRead() {
|
||||
try {
|
||||
await apiClient.put('/admin/notifications/mark-all-read');
|
||||
|
||||
// Update local state
|
||||
this.notifications.forEach(n => n.is_read = true);
|
||||
this.stats.unread_count = 0;
|
||||
|
||||
Utils.showToast('All notifications marked as read', 'success');
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to mark all as read:', error);
|
||||
Utils.showToast('Failed to mark all as read', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete notification
|
||||
*/
|
||||
async deleteNotification(notificationId) {
|
||||
if (!confirm('Are you sure you want to delete this notification?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/notifications/${notificationId}`);
|
||||
|
||||
// Remove from local state
|
||||
const wasUnread = this.notifications.find(n => n.id === notificationId && !n.is_read);
|
||||
this.notifications = this.notifications.filter(n => n.id !== notificationId);
|
||||
this.stats.total = Math.max(0, this.stats.total - 1);
|
||||
if (wasUnread) {
|
||||
this.stats.unread_count = Math.max(0, this.stats.unread_count - 1);
|
||||
}
|
||||
|
||||
Utils.showToast('Notification deleted', 'success');
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to delete notification:', error);
|
||||
Utils.showToast('Failed to delete notification', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get notification icon based on type
|
||||
*/
|
||||
getNotificationIcon(type) {
|
||||
const icons = {
|
||||
'import_failure': window.$icon?.('x-circle', 'w-5 h-5') || '❌',
|
||||
'sync_issue': window.$icon?.('refresh', 'w-5 h-5') || '🔄',
|
||||
'vendor_alert': window.$icon?.('exclamation-triangle', 'w-5 h-5') || '⚠️',
|
||||
'system_health': window.$icon?.('heart', 'w-5 h-5') || '💓',
|
||||
'security': window.$icon?.('shield-exclamation', 'w-5 h-5') || '🛡️',
|
||||
'performance': window.$icon?.('chart-bar', 'w-5 h-5') || '📊',
|
||||
'info': window.$icon?.('information-circle', 'w-5 h-5') || 'ℹ️'
|
||||
};
|
||||
return icons[type] || window.$icon?.('bell', 'w-5 h-5') || '🔔';
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// PLATFORM ALERTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load platform alerts
|
||||
*/
|
||||
async loadAlerts() {
|
||||
this.loadingAlerts = true;
|
||||
try {
|
||||
this.alertSkip = (this.alertPage - 1) * this.alertLimit;
|
||||
const params = new URLSearchParams();
|
||||
params.append('skip', this.alertSkip);
|
||||
params.append('limit', this.alertLimit);
|
||||
|
||||
if (this.alertFilters.severity) params.append('severity', this.alertFilters.severity);
|
||||
if (this.alertFilters.is_resolved !== '') params.append('is_resolved', this.alertFilters.is_resolved);
|
||||
|
||||
const response = await apiClient.get(`/admin/notifications/alerts?${params}`);
|
||||
this.alerts = response.alerts || [];
|
||||
this.alertStats.total = response.total || 0;
|
||||
this.alertStats.active_alerts = response.active_count || 0;
|
||||
this.alertStats.critical_alerts = response.critical_count || 0;
|
||||
|
||||
notificationsLog.debug(`Loaded ${this.alerts.length} alerts`);
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to load alerts:', error);
|
||||
Utils.showToast('Failed to load alerts', 'error');
|
||||
} finally {
|
||||
this.loadingAlerts = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load alert statistics
|
||||
*/
|
||||
async loadAlertStats() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/notifications/alerts/stats');
|
||||
this.alertStats = {
|
||||
...this.alertStats,
|
||||
total: response.total || 0,
|
||||
active_alerts: response.active || 0,
|
||||
critical_alerts: response.critical || 0,
|
||||
resolved_today: response.resolved_today || 0,
|
||||
by_type: response.by_type || {},
|
||||
by_severity: response.by_severity || {}
|
||||
};
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to load alert stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve alert
|
||||
*/
|
||||
async resolveAlert(alert) {
|
||||
const notes = prompt('Resolution notes (optional):');
|
||||
if (notes === null) return; // User cancelled
|
||||
|
||||
try {
|
||||
await apiClient.put(`/admin/notifications/alerts/${alert.id}/resolve`, {
|
||||
resolution_notes: notes
|
||||
});
|
||||
|
||||
// Update local state
|
||||
alert.is_resolved = true;
|
||||
alert.resolution_notes = notes;
|
||||
this.alertStats.active_alerts = Math.max(0, this.alertStats.active_alerts - 1);
|
||||
if (alert.severity === 'critical') {
|
||||
this.alertStats.critical_alerts = Math.max(0, this.alertStats.critical_alerts - 1);
|
||||
}
|
||||
this.alertStats.resolved_today++;
|
||||
|
||||
Utils.showToast('Alert resolved successfully', 'success');
|
||||
} catch (error) {
|
||||
notificationsLog.error('Failed to resolve alert:', error);
|
||||
Utils.showToast('Failed to resolve alert', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
|
||||
// Show relative time for recent dates
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 172800) return 'Yesterday';
|
||||
|
||||
// Show full date for older dates
|
||||
return date.toLocaleString();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Make available globally
|
||||
window.adminNotifications = adminNotifications;
|
||||
Reference in New Issue
Block a user