// static/admin/js/marketplace-letzshop.js /** * Admin marketplace Letzshop management page logic * Unified page for Products (Import/Export), Orders, and Settings */ // Use centralized logger const marketplaceLetzshopLog = window.LogConfig.createLogger('MARKETPLACE-LETZSHOP'); marketplaceLetzshopLog.info('Loading...'); function adminMarketplaceLetzshop() { marketplaceLetzshopLog.info('adminMarketplaceLetzshop() called'); return { // Inherit base layout state ...data(), // Set page identifier currentPage: 'marketplace-letzshop', // Tab state activeTab: 'products', // Loading states loading: false, importing: false, exporting: false, importingOrders: false, importingHistorical: false, loadingOrders: false, loadingJobs: false, savingCredentials: false, savingCsvUrls: false, testingConnection: false, submittingTracking: false, // Historical import state historicalImportResult: null, historicalImportJobId: null, historicalImportProgress: null, historicalImportPollInterval: null, // Messages error: '', successMessage: '', // Tom Select instance tomSelectInstance: null, // Selected vendor selectedVendor: null, // Letzshop status for selected vendor letzshopStatus: { is_configured: false, auto_sync_enabled: false, last_sync_at: null, last_sync_status: null }, // Credentials credentials: null, showApiKey: false, // Import form importForm: { csv_url: '', language: 'fr', batch_size: 1000 }, // Export settings exportLanguage: 'fr', exportIncludeInactive: false, // Settings form settingsForm: { api_key: '', auto_sync_enabled: false, sync_interval_minutes: 15, test_mode_enabled: false, letzshop_csv_url_fr: '', letzshop_csv_url_en: '', letzshop_csv_url_de: '', default_carrier: '', carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/', carrier_colissimo_label_url: '', carrier_xpresslogistics_label_url: '' }, savingCarrierSettings: false, // Unified pagination (shared across tabs, updated based on activeTab) pagination: { page: 1, per_page: 20, total: 0, pages: 0 }, // Orders orders: [], ordersFilter: '', ordersSearch: '', ordersHasDeclinedItems: false, orderStats: { pending: 0, processing: 0, shipped: 0, delivered: 0, cancelled: 0, total: 0, has_declined_items: 0 }, // Exceptions exceptions: [], exceptionsFilter: '', exceptionsSearch: '', exceptionStats: { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 }, loadingExceptions: false, // Jobs jobs: [], jobsFilter: { type: '', status: '' }, // Products Tab products: [], loadingProducts: false, productFilters: { search: '', is_active: '' }, productStats: { total: 0, active: 0, inactive: 0, last_sync: null }, showImportModal: false, // Modals showTrackingModal: false, showOrderModal: false, showResolveModal: false, showJobDetailsModal: false, selectedOrder: null, selectedJobDetails: null, selectedExceptionForResolve: null, trackingForm: { tracking_number: '', tracking_provider: '' }, resolveForm: { product_id: null, product_name: '', notes: '', bulk_resolve: false }, productSearchQuery: '', productSearchResults: [], searchingProducts: false, submittingResolve: false, // Computed: Total pages get totalPages() { return this.pagination.pages || Math.ceil(this.pagination.total / this.pagination.per_page) || 0; }, // Computed: Start index for pagination display get startIndex() { if (this.pagination.total === 0) return 0; return (this.pagination.page - 1) * this.pagination.per_page + 1; }, // Computed: End index for pagination display get endIndex() { const end = this.pagination.page * this.pagination.per_page; return end > this.pagination.total ? this.pagination.total : end; }, // Computed: Page numbers for pagination get pageNumbers() { const pages = []; const totalPages = this.totalPages; const current = this.pagination.page; if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) { pages.push(i); } } else { pages.push(1); if (current > 3) { pages.push('...'); } const start = Math.max(2, current - 1); const end = Math.min(totalPages - 1, current + 1); for (let i = start; i <= end; i++) { pages.push(i); } if (current < totalPages - 2) { pages.push('...'); } pages.push(totalPages); } return pages; }, // Pagination: Previous page previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this._loadCurrentTabData(); } }, // Pagination: Next page nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this._loadCurrentTabData(); } }, // Pagination: Go to specific page goToPage(pageNum) { if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) { this.pagination.page = pageNum; this._loadCurrentTabData(); } }, // Helper: Load data for current active tab _loadCurrentTabData() { switch (this.activeTab) { case 'orders': this.loadOrders(); break; case 'products': this.loadProducts(); break; case 'jobs': this.loadJobs(); break; case 'exceptions': this.loadExceptions(); break; } }, async init() { marketplaceLetzshopLog.info('init() called'); // Guard against multiple initialization if (window._marketplaceLetzshopInitialized) { marketplaceLetzshopLog.warn('Already initialized, skipping'); return; } window._marketplaceLetzshopInitialized = true; // Load platform settings for pagination if (window.PlatformSettings) { const rowsPerPage = await window.PlatformSettings.getRowsPerPage(); this.pagination.per_page = rowsPerPage; marketplaceLetzshopLog.info('Loaded rows per page setting:', rowsPerPage); } // Initialize Tom Select after a short delay to ensure DOM is ready this.$nextTick(() => { this.initTomSelect(); }); // Watch for tab changes to reload relevant data this.$watch('activeTab', async (newTab) => { marketplaceLetzshopLog.info('Tab changed to:', newTab); // Reset pagination to page 1 when switching tabs this.pagination.page = 1; this.pagination.total = 0; this.pagination.pages = 0; if (newTab === 'jobs') { await this.loadJobs(); } else if (newTab === 'products') { await this.loadProducts(); } else if (newTab === 'orders') { await this.loadOrders(); } else if (newTab === 'exceptions') { await Promise.all([this.loadExceptions(), this.loadExceptionStats()]); } }); // Check localStorage for last selected vendor const savedVendorId = localStorage.getItem('letzshop_selected_vendor_id'); if (savedVendorId) { marketplaceLetzshopLog.info('Restoring saved vendor:', savedVendorId); // Load saved vendor after TomSelect is ready setTimeout(async () => { await this.restoreSavedVendor(parseInt(savedVendorId)); }, 200); } else { // Load cross-vendor data when no vendor selected await this.loadCrossVendorData(); } marketplaceLetzshopLog.info('Initialization complete'); }, /** * Restore previously selected vendor from localStorage */ async restoreSavedVendor(vendorId) { try { // Load vendor details first const vendor = await apiClient.get(`/admin/vendors/${vendorId}`); // Add to TomSelect and select (silent to avoid double-triggering) if (this.tomSelectInstance) { this.tomSelectInstance.addOption({ id: vendor.id, name: vendor.name, vendor_code: vendor.vendor_code }); this.tomSelectInstance.setValue(vendor.id, true); } // Manually call selectVendor since we used silent mode above // This sets selectedVendor and loads all vendor-specific data await this.selectVendor(vendor.id); marketplaceLetzshopLog.info('Restored saved vendor:', vendor.name); } catch (error) { marketplaceLetzshopLog.error('Failed to restore saved vendor:', error); // Clear invalid saved vendor localStorage.removeItem('letzshop_selected_vendor_id'); // Load cross-vendor data instead await this.loadCrossVendorData(); } }, /** * Load cross-vendor aggregate data (when no vendor is selected) */ async loadCrossVendorData() { marketplaceLetzshopLog.info('Loading cross-vendor data'); this.loading = true; try { await Promise.all([ this.loadProducts(), this.loadOrders(), this.loadExceptions(), this.loadExceptionStats(), this.loadJobs() ]); } catch (error) { marketplaceLetzshopLog.error('Failed to load cross-vendor data:', error); } finally { this.loading = false; } }, /** * Initialize Tom Select for vendor autocomplete */ initTomSelect() { const selectEl = this.$refs.vendorSelect; if (!selectEl) { marketplaceLetzshopLog.error('Vendor select element not found'); return; } // Wait for TomSelect to be available if (typeof TomSelect === 'undefined') { marketplaceLetzshopLog.warn('TomSelect not loaded yet, retrying...'); setTimeout(() => this.initTomSelect(), 100); return; } marketplaceLetzshopLog.info('Initializing Tom Select'); this.tomSelectInstance = new TomSelect(selectEl, { valueField: 'id', labelField: 'name', searchField: ['name', 'vendor_code'], maxOptions: 50, placeholder: 'Search vendor by name or code...', load: async (query, callback) => { if (query.length < 2) { callback([]); return; } try { const response = await apiClient.get(`/admin/vendors?search=${encodeURIComponent(query)}&limit=50`); const vendors = response.vendors.map(v => ({ id: v.id, name: v.name, vendor_code: v.vendor_code })); callback(vendors); } catch (error) { marketplaceLetzshopLog.error('Failed to search vendors:', error); callback([]); } }, render: { option: (data, escape) => { return `
${escape(data.name)} ${escape(data.vendor_code)}
`; }, item: (data, escape) => { return `
${escape(data.name)} (${escape(data.vendor_code)})
`; } }, onChange: async (value) => { if (value) { await this.selectVendor(parseInt(value)); } else { this.clearVendorSelection(); } } }); }, /** * Handle vendor selection */ async selectVendor(vendorId) { marketplaceLetzshopLog.info('Selecting vendor:', vendorId); this.loading = true; this.error = ''; try { // Load vendor details const vendor = await apiClient.get(`/admin/vendors/${vendorId}`); this.selectedVendor = vendor; // Save to localStorage for persistence localStorage.setItem('letzshop_selected_vendor_id', vendorId.toString()); // Pre-fill settings form with CSV URLs this.settingsForm.letzshop_csv_url_fr = vendor.letzshop_csv_url_fr || ''; this.settingsForm.letzshop_csv_url_en = vendor.letzshop_csv_url_en || ''; this.settingsForm.letzshop_csv_url_de = vendor.letzshop_csv_url_de || ''; // Load Letzshop status and credentials await this.loadLetzshopStatus(); // Load orders, exceptions, products, and jobs await Promise.all([ this.loadOrders(), this.loadExceptions(), this.loadExceptionStats(), this.loadProducts(), this.loadJobs() ]); marketplaceLetzshopLog.info('Vendor loaded:', vendor.name); } catch (error) { marketplaceLetzshopLog.error('Failed to load vendor:', error); this.error = error.message || 'Failed to load vendor'; } finally { this.loading = false; } }, /** * Clear vendor selection */ async clearVendorSelection() { // Clear TomSelect dropdown if (this.tomSelectInstance) { this.tomSelectInstance.clear(); } this.selectedVendor = null; this.letzshopStatus = { is_configured: false }; this.credentials = null; this.ordersFilter = ''; this.ordersSearch = ''; this.ordersHasDeclinedItems = false; this.exceptionsFilter = ''; this.exceptionsSearch = ''; this.settingsForm = { api_key: '', auto_sync_enabled: false, sync_interval_minutes: 15, test_mode_enabled: false, letzshop_csv_url_fr: '', letzshop_csv_url_en: '', letzshop_csv_url_de: '', default_carrier: '', carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/', carrier_colissimo_label_url: '', carrier_xpresslogistics_label_url: '' }; // Clear localStorage localStorage.removeItem('letzshop_selected_vendor_id'); // Load cross-vendor data await this.loadCrossVendorData(); }, /** * Load Letzshop status and credentials for selected vendor */ async loadLetzshopStatus() { if (!this.selectedVendor) return; try { const response = await apiClient.get(`/admin/letzshop/vendors/${this.selectedVendor.id}/credentials`); this.credentials = response; this.letzshopStatus = { is_configured: true, auto_sync_enabled: response.auto_sync_enabled, last_sync_at: response.last_sync_at, last_sync_status: response.last_sync_status }; this.settingsForm.auto_sync_enabled = response.auto_sync_enabled; this.settingsForm.sync_interval_minutes = response.sync_interval_minutes || 15; this.settingsForm.test_mode_enabled = response.test_mode_enabled || false; this.settingsForm.default_carrier = response.default_carrier || ''; this.settingsForm.carrier_greco_label_url = response.carrier_greco_label_url || 'https://dispatchweb.fr/Tracky/Home/'; this.settingsForm.carrier_colissimo_label_url = response.carrier_colissimo_label_url || ''; this.settingsForm.carrier_xpresslogistics_label_url = response.carrier_xpresslogistics_label_url || ''; } catch (error) { if (error.status === 404) { // Not configured this.letzshopStatus = { is_configured: false }; this.credentials = null; } else { marketplaceLetzshopLog.error('Failed to load Letzshop status:', error); } } }, /** * Refresh all data for selected vendor */ async refreshData() { if (!this.selectedVendor) return; await this.selectVendor(this.selectedVendor.id); }, // ═══════════════════════════════════════════════════════════════ // PRODUCTS TAB - LISTING (Letzshop marketplace products) // ═══════════════════════════════════════════════════════════════ /** * Load Letzshop products * When vendor is selected: shows products for that vendor * When no vendor selected: shows ALL Letzshop marketplace products */ async loadProducts() { this.loadingProducts = true; try { const params = new URLSearchParams({ marketplace: 'Letzshop', skip: ((this.pagination.page - 1) * this.pagination.per_page).toString(), limit: this.pagination.per_page.toString() }); // Filter by vendor if one is selected if (this.selectedVendor) { params.append('vendor_name', this.selectedVendor.name); } if (this.productFilters.search) { params.append('search', this.productFilters.search); } if (this.productFilters.is_active !== '') { params.append('is_active', this.productFilters.is_active); } const response = await apiClient.get(`/admin/products?${params}`); this.products = response.products || []; this.pagination.total = response.total || 0; this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); // Load stats separately await this.loadProductStats(); } catch (error) { marketplaceLetzshopLog.error('Failed to load products:', error); this.products = []; this.pagination.total = 0; } finally { this.loadingProducts = false; } }, /** * Load product statistics for Letzshop products * Shows stats for selected vendor or all Letzshop products */ async loadProductStats() { try { const params = new URLSearchParams({ marketplace: 'Letzshop' }); // Filter by vendor if one is selected if (this.selectedVendor) { params.append('vendor_name', this.selectedVendor.name); } const response = await apiClient.get(`/admin/products/stats?${params}`); this.productStats = { total: response.total || 0, active: response.active || 0, inactive: response.inactive || 0, last_sync: null // TODO: Get from last import job }; } catch (error) { marketplaceLetzshopLog.error('Failed to load product stats:', error); } }, // ═══════════════════════════════════════════════════════════════ // PRODUCTS TAB - IMPORT // ═══════════════════════════════════════════════════════════════ /** * Import all languages from configured CSV URLs */ async startImportAllLanguages() { if (!this.selectedVendor) return; this.importing = true; this.error = ''; this.successMessage = ''; this.showImportModal = false; try { const languages = []; if (this.selectedVendor.letzshop_csv_url_fr) languages.push({ url: this.selectedVendor.letzshop_csv_url_fr, lang: 'fr' }); if (this.selectedVendor.letzshop_csv_url_en) languages.push({ url: this.selectedVendor.letzshop_csv_url_en, lang: 'en' }); if (this.selectedVendor.letzshop_csv_url_de) languages.push({ url: this.selectedVendor.letzshop_csv_url_de, lang: 'de' }); if (languages.length === 0) { this.error = 'No CSV URLs configured. Please set them in Settings.'; this.importing = false; return; } // Start import jobs for all languages for (const { url, lang } of languages) { await apiClient.post('/admin/marketplace-import-jobs', { vendor_id: this.selectedVendor.id, source_url: url, marketplace: 'Letzshop', language: lang, batch_size: this.importForm.batch_size }); } this.successMessage = `Import started for ${languages.length} language(s)`; await this.loadJobs(); } catch (error) { marketplaceLetzshopLog.error('Failed to start import:', error); this.error = error.message || 'Failed to start import'; } finally { this.importing = false; } }, /** * Import from custom URL */ async startImportFromUrl() { if (!this.selectedVendor || !this.importForm.csv_url) return; this.importing = true; this.error = ''; this.successMessage = ''; this.showImportModal = false; try { await apiClient.post('/admin/marketplace-import-jobs', { vendor_id: this.selectedVendor.id, source_url: this.importForm.csv_url, marketplace: 'Letzshop', language: this.importForm.language, batch_size: this.importForm.batch_size }); this.successMessage = 'Import job started successfully'; this.importForm.csv_url = ''; await this.loadJobs(); } catch (error) { marketplaceLetzshopLog.error('Failed to start import:', error); this.error = error.message || 'Failed to start import'; } finally { this.importing = false; } }, /** * Legacy method for backwards compatibility */ async startImport() { return this.startImportFromUrl(); }, // ═══════════════════════════════════════════════════════════════ // PRODUCTS TAB - EXPORT // ═══════════════════════════════════════════════════════════════ /** * Export products for all languages to Letzshop pickup folder */ async exportAllLanguages() { if (!this.selectedVendor) return; this.exporting = true; this.error = ''; this.successMessage = ''; try { const response = await apiClient.post(`/admin/vendors/${this.selectedVendor.id}/export/letzshop`, { include_inactive: this.exportIncludeInactive }); this.successMessage = response.message || 'Export completed. CSV files are ready for Letzshop pickup.'; marketplaceLetzshopLog.info('Export completed:', response); } catch (error) { marketplaceLetzshopLog.error('Failed to export:', error); this.error = error.message || 'Failed to export products'; } finally { this.exporting = false; } }, /** * Legacy download export method */ async downloadExport() { return this.exportAllLanguages(); }, /** * Format price for display */ formatPrice(price, currency = 'EUR') { if (price == null) return '-'; return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency }).format(price); }, // ═══════════════════════════════════════════════════════════════ // ORDERS TAB // ═══════════════════════════════════════════════════════════════ /** * Load orders for selected vendor (or all vendors if none selected) */ async loadOrders() { this.loadingOrders = true; this.error = ''; try { const params = new URLSearchParams({ skip: ((this.pagination.page - 1) * this.pagination.per_page).toString(), limit: this.pagination.per_page.toString() }); if (this.ordersFilter) { params.append('status', this.ordersFilter); } if (this.ordersHasDeclinedItems) { params.append('has_declined_items', 'true'); } if (this.ordersSearch) { params.append('search', this.ordersSearch); } // Use cross-vendor endpoint (with optional vendor_id filter) let url = '/admin/letzshop/orders'; if (this.selectedVendor) { params.append('vendor_id', this.selectedVendor.id.toString()); } const response = await apiClient.get(`${url}?${params}`); this.orders = response.orders || []; this.pagination.total = response.total || 0; this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); // Use server-side stats (counts all orders, not just visible page) if (response.stats) { this.orderStats = response.stats; } else { // Fallback to client-side calculation for backwards compatibility this.updateOrderStats(); } } catch (error) { marketplaceLetzshopLog.error('Failed to load orders:', error); this.error = error.message || 'Failed to load orders'; } finally { this.loadingOrders = false; } }, /** * Update order stats based on current orders (fallback method) * * Note: Server now returns stats with all orders counted. * This method is kept as a fallback for backwards compatibility. */ updateOrderStats() { // Reset stats this.orderStats = { pending: 0, processing: 0, shipped: 0, delivered: 0, cancelled: 0, total: 0, has_declined_items: 0 }; // Count from orders list (only visible page - not accurate for totals) for (const order of this.orders) { if (this.orderStats.hasOwnProperty(order.status)) { this.orderStats[order.status]++; } this.orderStats.total++; } }, /** * Import orders from Letzshop */ async importOrders() { if (!this.selectedVendor || !this.letzshopStatus.is_configured) return; this.importingOrders = true; this.error = ''; this.successMessage = ''; try { await apiClient.post(`/admin/letzshop/vendors/${this.selectedVendor.id}/sync`); this.successMessage = 'Orders imported successfully'; await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to import orders:', error); this.error = error.message || 'Failed to import orders'; } finally { this.importingOrders = false; } }, /** * Import historical orders from Letzshop (confirmed and declined orders) * Uses background job with polling for progress tracking */ async importHistoricalOrders() { if (!this.selectedVendor || !this.letzshopStatus.is_configured) return; this.importingHistorical = true; this.error = ''; this.successMessage = ''; this.historicalImportResult = null; this.historicalImportProgress = { status: 'starting', message: 'Starting historical import...', current_phase: null, current_page: 0, shipments_fetched: 0, orders_processed: 0, }; try { // Start the import job const response = await apiClient.post( `/admin/letzshop/vendors/${this.selectedVendor.id}/import-history` ); this.historicalImportJobId = response.job_id; marketplaceLetzshopLog.info('Historical import job started:', response); // Start polling for progress this.startHistoricalImportPolling(); } catch (error) { marketplaceLetzshopLog.error('Failed to start historical import:', error); this.error = error.message || 'Failed to start historical import'; this.importingHistorical = false; this.historicalImportProgress = null; } }, /** * Start polling for historical import progress */ startHistoricalImportPolling() { // Poll every 2 seconds this.historicalImportPollInterval = setInterval(async () => { await this.pollHistoricalImportStatus(); }, 2000); }, /** * Poll historical import status */ async pollHistoricalImportStatus() { if (!this.historicalImportJobId || !this.selectedVendor) { this.stopHistoricalImportPolling(); return; } try { const status = await apiClient.get( `/admin/letzshop/vendors/${this.selectedVendor.id}/import-history/${this.historicalImportJobId}/status` ); // Update progress display this.historicalImportProgress = { status: status.status, message: this.formatProgressMessage(status), current_phase: status.current_phase, current_page: status.current_page, total_pages: status.total_pages, shipments_fetched: status.shipments_fetched, orders_processed: status.orders_processed, }; // Check if complete or failed if (status.status === 'completed' || status.status === 'failed') { this.stopHistoricalImportPolling(); this.importingHistorical = false; if (status.status === 'completed') { // Combine stats from both phases const confirmed = status.confirmed_stats || {}; const pending = status.declined_stats || {}; // Actually unconfirmed/pending this.historicalImportResult = { imported: (confirmed.imported || 0) + (pending.imported || 0), updated: (confirmed.updated || 0) + (pending.updated || 0), skipped: (confirmed.skipped || 0) + (pending.skipped || 0), products_matched: (confirmed.products_matched || 0) + (pending.products_matched || 0), products_not_found: (confirmed.products_not_found || 0) + (pending.products_not_found || 0), }; const stats = this.historicalImportResult; // Build a meaningful summary message const parts = []; if (stats.imported > 0) parts.push(`${stats.imported} imported`); if (stats.updated > 0) parts.push(`${stats.updated} updated`); if (stats.skipped > 0) parts.push(`${stats.skipped} already synced`); this.successMessage = parts.length > 0 ? `Historical import complete: ${parts.join(', ')}` : 'Historical import complete: no orders found'; marketplaceLetzshopLog.info('Historical import completed:', status); // Reload orders to show new data await this.loadOrders(); } else { this.error = status.error_message || 'Historical import failed'; marketplaceLetzshopLog.error('Historical import failed:', status); } this.historicalImportProgress = null; this.historicalImportJobId = null; } } catch (error) { marketplaceLetzshopLog.error('Failed to poll import status:', error); // Don't stop polling on transient errors } }, /** * Stop polling for historical import progress */ stopHistoricalImportPolling() { if (this.historicalImportPollInterval) { clearInterval(this.historicalImportPollInterval); this.historicalImportPollInterval = null; } }, /** * Format progress message for display */ formatProgressMessage(status) { // Map phase to display name const phaseNames = { 'confirmed': 'confirmed', 'unconfirmed': 'pending', 'declined': 'declined', // Legacy support }; const phase = phaseNames[status.current_phase] || status.current_phase || 'orders'; if (status.status === 'fetching') { if (status.total_pages) { return `Fetching ${phase} orders: page ${status.current_page} of ${status.total_pages} (${status.shipments_fetched} fetched)`; } return `Fetching ${phase} orders: page ${status.current_page}... (${status.shipments_fetched} fetched)`; } if (status.status === 'processing') { return `Processing ${phase} orders: ${status.orders_processed} processed...`; } if (status.status === 'pending') { return 'Starting historical import...'; } return status.status.charAt(0).toUpperCase() + status.status.slice(1); }, /** * Confirm an order */ async confirmOrder(order) { if (!this.selectedVendor) return; try { await apiClient.post(`/admin/letzshop/vendors/${this.selectedVendor.id}/orders/${order.id}/confirm`); this.successMessage = 'Order confirmed'; await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to confirm order:', error); this.error = error.message || 'Failed to confirm order'; } }, /** * Decline an order (all items) */ async declineOrder(order) { if (!this.selectedVendor) return; if (!confirm('Are you sure you want to decline this order? All items will be marked as unavailable.')) return; try { await apiClient.post(`/admin/letzshop/vendors/${this.selectedVendor.id}/orders/${order.id}/reject`); this.successMessage = 'Order declined'; await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to decline order:', error); this.error = error.message || 'Failed to decline order'; } }, /** * Open tracking modal */ openTrackingModal(order) { this.selectedOrder = order; this.trackingForm = { tracking_number: order.tracking_number || '', tracking_provider: order.tracking_provider || '' }; this.showTrackingModal = true; }, /** * Submit tracking information */ async submitTracking() { if (!this.selectedVendor || !this.selectedOrder) return; this.submittingTracking = true; try { await apiClient.post( `/admin/letzshop/vendors/${this.selectedVendor.id}/orders/${this.selectedOrder.id}/tracking`, this.trackingForm ); this.successMessage = 'Tracking information saved'; this.showTrackingModal = false; await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to save tracking:', error); this.error = error.message || 'Failed to save tracking'; } finally { this.submittingTracking = false; } }, /** * View order details */ viewOrderDetails(order) { this.selectedOrder = order; this.showOrderModal = true; }, /** * Confirm a single order item */ async confirmInventoryUnit(order, item, index) { if (!this.selectedVendor) return; // Use external_item_id (Letzshop inventory unit ID) const itemId = item.external_item_id; if (!itemId) { this.error = 'Item has no external ID'; return; } try { await apiClient.post( `/admin/letzshop/vendors/${this.selectedVendor.id}/orders/${order.id}/items/${itemId}/confirm` ); // Update local state this.selectedOrder.items[index].item_state = 'confirmed_available'; this.successMessage = 'Item confirmed'; // Reload orders to get updated status await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to confirm item:', error); this.error = error.message || 'Failed to confirm item'; } }, /** * Decline a single order item */ async declineInventoryUnit(order, item, index) { if (!this.selectedVendor) return; // Use external_item_id (Letzshop inventory unit ID) const itemId = item.external_item_id; if (!itemId) { this.error = 'Item has no external ID'; return; } try { await apiClient.post( `/admin/letzshop/vendors/${this.selectedVendor.id}/orders/${order.id}/items/${itemId}/decline` ); // Update local state this.selectedOrder.items[index].item_state = 'confirmed_unavailable'; this.successMessage = 'Item declined'; // Reload orders to get updated status await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to decline item:', error); this.error = error.message || 'Failed to decline item'; } }, /** * Confirm all items in an order */ async confirmAllItems(order) { if (!this.selectedVendor) return; if (!confirm('Are you sure you want to confirm all items in this order?')) return; try { await apiClient.post( `/admin/letzshop/vendors/${this.selectedVendor.id}/orders/${order.id}/confirm` ); this.successMessage = 'All items confirmed'; this.showOrderModal = false; await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to confirm all items:', error); this.error = error.message || 'Failed to confirm all items'; } }, /** * Decline all items in an order */ async declineAllItems(order) { if (!this.selectedVendor) return; if (!confirm('Are you sure you want to decline all items in this order?')) return; try { await apiClient.post( `/admin/letzshop/vendors/${this.selectedVendor.id}/orders/${order.id}/reject` ); this.successMessage = 'All items declined'; this.showOrderModal = false; await this.loadOrders(); } catch (error) { marketplaceLetzshopLog.error('Failed to decline all items:', error); this.error = error.message || 'Failed to decline all items'; } }, // ═══════════════════════════════════════════════════════════════ // SETTINGS TAB // ═══════════════════════════════════════════════════════════════ /** * Save Letzshop credentials */ async saveCredentials() { if (!this.selectedVendor) return; this.savingCredentials = true; this.error = ''; this.successMessage = ''; try { const payload = { auto_sync_enabled: this.settingsForm.auto_sync_enabled, sync_interval_minutes: parseInt(this.settingsForm.sync_interval_minutes), test_mode_enabled: this.settingsForm.test_mode_enabled }; // Only include API key if it was provided (not just placeholder) if (this.settingsForm.api_key && this.settingsForm.api_key.length > 0) { payload.api_key = this.settingsForm.api_key; } if (this.credentials) { // Update existing await apiClient.patch(`/admin/letzshop/vendors/${this.selectedVendor.id}/credentials`, payload); } else { // Create new (API key required) if (!payload.api_key) { this.error = 'API key is required for initial setup'; this.savingCredentials = false; return; } await apiClient.post(`/admin/letzshop/vendors/${this.selectedVendor.id}/credentials`, payload); } this.successMessage = 'Credentials saved successfully'; this.settingsForm.api_key = ''; // Clear the input await this.loadLetzshopStatus(); } catch (error) { marketplaceLetzshopLog.error('Failed to save credentials:', error); this.error = error.message || 'Failed to save credentials'; } finally { this.savingCredentials = false; } }, /** * Test Letzshop connection */ async testConnection() { if (!this.selectedVendor || !this.letzshopStatus.is_configured) return; this.testingConnection = true; this.error = ''; this.successMessage = ''; try { await apiClient.post(`/admin/letzshop/vendors/${this.selectedVendor.id}/test`); this.successMessage = 'Connection test successful!'; } catch (error) { marketplaceLetzshopLog.error('Connection test failed:', error); this.error = error.message || 'Connection test failed'; } finally { this.testingConnection = false; } }, /** * Delete Letzshop credentials */ async deleteCredentials() { if (!this.selectedVendor) return; if (!confirm('Are you sure you want to remove the Letzshop configuration? This will disable all Letzshop features for this vendor.')) { return; } try { await apiClient.delete(`/admin/letzshop/vendors/${this.selectedVendor.id}/credentials`); this.successMessage = 'Credentials removed'; this.credentials = null; this.letzshopStatus = { is_configured: false }; } catch (error) { marketplaceLetzshopLog.error('Failed to delete credentials:', error); this.error = error.message || 'Failed to remove credentials'; } }, /** * Save CSV URLs to vendor */ async saveCsvUrls() { if (!this.selectedVendor) return; this.savingCsvUrls = true; this.error = ''; this.successMessage = ''; try { await apiClient.patch(`/admin/vendors/${this.selectedVendor.id}`, { letzshop_csv_url_fr: this.settingsForm.letzshop_csv_url_fr || null, letzshop_csv_url_en: this.settingsForm.letzshop_csv_url_en || null, letzshop_csv_url_de: this.settingsForm.letzshop_csv_url_de || null }); // Update local vendor object this.selectedVendor.letzshop_csv_url_fr = this.settingsForm.letzshop_csv_url_fr; this.selectedVendor.letzshop_csv_url_en = this.settingsForm.letzshop_csv_url_en; this.selectedVendor.letzshop_csv_url_de = this.settingsForm.letzshop_csv_url_de; this.successMessage = 'CSV URLs saved successfully'; } catch (error) { marketplaceLetzshopLog.error('Failed to save CSV URLs:', error); this.error = error.message || 'Failed to save CSV URLs'; } finally { this.savingCsvUrls = false; } }, /** * Save carrier settings */ async saveCarrierSettings() { if (!this.selectedVendor || !this.credentials) return; this.savingCarrierSettings = true; this.error = ''; this.successMessage = ''; try { await apiClient.patch(`/admin/letzshop/vendors/${this.selectedVendor.id}/credentials`, { default_carrier: this.settingsForm.default_carrier || null, carrier_greco_label_url: this.settingsForm.carrier_greco_label_url || null, carrier_colissimo_label_url: this.settingsForm.carrier_colissimo_label_url || null, carrier_xpresslogistics_label_url: this.settingsForm.carrier_xpresslogistics_label_url || null }); this.successMessage = 'Carrier settings saved successfully'; } catch (error) { marketplaceLetzshopLog.error('Failed to save carrier settings:', error); this.error = error.message || 'Failed to save carrier settings'; } finally { this.savingCarrierSettings = false; } }, // ═══════════════════════════════════════════════════════════════ // EXCEPTIONS // ═══════════════════════════════════════════════════════════════ /** * Load exceptions for selected vendor (or all vendors if none selected) */ async loadExceptions() { this.loadingExceptions = true; try { const params = new URLSearchParams({ skip: ((this.pagination.page - 1) * this.pagination.per_page).toString(), limit: this.pagination.per_page.toString() }); if (this.exceptionsFilter) { params.append('status', this.exceptionsFilter); } if (this.exceptionsSearch) { params.append('search', this.exceptionsSearch); } // Add vendor filter if a vendor is selected if (this.selectedVendor) { params.append('vendor_id', this.selectedVendor.id.toString()); } const response = await apiClient.get(`/admin/order-exceptions?${params}`); this.exceptions = response.exceptions || []; this.pagination.total = response.total || 0; this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); } catch (error) { marketplaceLetzshopLog.error('Failed to load exceptions:', error); this.error = error.message || 'Failed to load exceptions'; } finally { this.loadingExceptions = false; } }, /** * Load exception statistics for selected vendor (or all vendors if none selected) */ async loadExceptionStats() { try { const params = new URLSearchParams(); if (this.selectedVendor) { params.append('vendor_id', this.selectedVendor.id.toString()); } const response = await apiClient.get(`/admin/order-exceptions/stats?${params}`); this.exceptionStats = response; } catch (error) { marketplaceLetzshopLog.error('Failed to load exception stats:', error); this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 }; } }, /** * Open the resolve modal for an exception */ openResolveModal(exception) { this.selectedExceptionForResolve = exception; this.resolveForm = { product_id: null, product_name: '', notes: '', bulk_resolve: false }; this.productSearchQuery = ''; this.productSearchResults = []; this.showResolveModal = true; }, /** * Search for products to assign to exception */ async searchProducts() { if (!this.productSearchQuery || this.productSearchQuery.length < 2) { this.productSearchResults = []; return; } this.searchingProducts = true; try { const response = await apiClient.get(`/admin/products?vendor_id=${this.selectedVendor.id}&search=${encodeURIComponent(this.productSearchQuery)}&limit=10`); this.productSearchResults = response.products || []; } catch (error) { marketplaceLetzshopLog.error('Failed to search products:', error); this.productSearchResults = []; } finally { this.searchingProducts = false; } }, /** * Select a product for resolving exception */ selectProductForResolve(product) { this.resolveForm.product_id = product.id; this.resolveForm.product_name = product.name || product.title; this.productSearchResults = []; this.productSearchQuery = ''; }, /** * Submit exception resolution */ async submitResolveException() { if (!this.selectedExceptionForResolve || !this.resolveForm.product_id) return; this.submittingResolve = true; try { if (this.resolveForm.bulk_resolve && this.selectedExceptionForResolve.original_gtin) { // Bulk resolve by GTIN const response = await apiClient.post(`/admin/order-exceptions/bulk-resolve?vendor_id=${this.selectedVendor.id}`, { gtin: this.selectedExceptionForResolve.original_gtin, product_id: this.resolveForm.product_id, notes: this.resolveForm.notes }); this.successMessage = `Resolved ${response.resolved_count} exception(s) for GTIN ${response.gtin}`; } else { // Single resolve await apiClient.post(`/admin/order-exceptions/${this.selectedExceptionForResolve.id}/resolve`, { product_id: this.resolveForm.product_id, notes: this.resolveForm.notes }); this.successMessage = 'Exception resolved successfully'; } this.showResolveModal = false; await Promise.all([ this.loadExceptions(), this.loadExceptionStats() ]); } catch (error) { marketplaceLetzshopLog.error('Failed to resolve exception:', error); this.error = error.message || 'Failed to resolve exception'; } finally { this.submittingResolve = false; } }, /** * Ignore an exception */ async ignoreException(exception) { if (!confirm('Are you sure you want to ignore this exception? The order will still be blocked from confirmation.')) { return; } try { await apiClient.post(`/admin/order-exceptions/${exception.id}/ignore`, { notes: 'Ignored via admin interface' }); this.successMessage = 'Exception marked as ignored'; await Promise.all([ this.loadExceptions(), this.loadExceptionStats() ]); } catch (error) { marketplaceLetzshopLog.error('Failed to ignore exception:', error); this.error = error.message || 'Failed to ignore exception'; } }, // ═══════════════════════════════════════════════════════════════ // JOBS TABLE // ═══════════════════════════════════════════════════════════════ /** * Load jobs for selected vendor or all vendors */ async loadJobs() { this.loadingJobs = true; try { const params = new URLSearchParams({ skip: ((this.pagination.page - 1) * this.pagination.per_page).toString(), limit: this.pagination.per_page.toString() }); if (this.jobsFilter.type) { params.append('job_type', this.jobsFilter.type); } if (this.jobsFilter.status) { params.append('status', this.jobsFilter.status); } // Use vendor-specific or global endpoint based on selection const endpoint = this.selectedVendor ? `/admin/letzshop/vendors/${this.selectedVendor.id}/jobs?${params}` : `/admin/letzshop/jobs?${params}`; const response = await apiClient.get(endpoint); this.jobs = response.jobs || []; this.pagination.total = response.total || 0; this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); } catch (error) { marketplaceLetzshopLog.error('Failed to load jobs:', error); // Don't show error for jobs - not critical } finally { this.loadingJobs = false; } }, /** * View job details in modal */ viewJobDetails(job) { marketplaceLetzshopLog.info('View job details:', job); this.selectedJobDetails = job; this.showJobDetailsModal = true; }, /** * View job errors */ async viewJobErrors(job) { if (job.type !== 'import' && job.type !== 'historical_import') return; try { const endpoint = job.type === 'import' ? `/admin/marketplace-import-jobs/${job.id}/errors` : `/admin/letzshop/historical-imports/${job.id}`; const response = await apiClient.get(endpoint); if (job.type === 'import') { const errors = response.errors || []; if (errors.length === 0) { Utils.showToast('No error details available', 'info'); return; } // Store errors and show in job details modal this.selectedJobDetails = { ...job, errors: errors.slice(0, 20) }; this.showJobDetailsModal = true; } else { // Historical import - show job details this.selectedJobDetails = { ...job, ...response }; this.showJobDetailsModal = true; } } catch (error) { marketplaceLetzshopLog.error('Failed to load job errors:', error); Utils.showToast('Failed to load error details', 'error'); } }, // ═══════════════════════════════════════════════════════════════ // UTILITIES // ═══════════════════════════════════════════════════════════════ /** * Format date for display */ formatDate(dateString) { if (!dateString) return 'N/A'; try { const date = new Date(dateString); return date.toLocaleString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return dateString; } }, /** * Format duration between two dates */ formatDuration(startDate, endDate) { if (!startDate) return '-'; if (!endDate) return 'In progress...'; try { const start = new Date(startDate); const end = new Date(endDate); const diffMs = end - start; if (diffMs < 1000) return '<1s'; if (diffMs < 60000) return `${Math.round(diffMs / 1000)}s`; if (diffMs < 3600000) return `${Math.round(diffMs / 60000)}m`; return `${Math.round(diffMs / 3600000)}h`; } catch { return '-'; } } }; }