From 7f0d32c18d6162193a3d3568655a3e6188ca347e Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 20 Dec 2025 14:09:57 +0100 Subject: [PATCH] feat: add Exceptions tab to Letzshop management page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Exceptions" tab to the Letzshop marketplace page for managing unmatched product exceptions from order imports. Features: - Exception list with search and status filtering - Stats cards showing pending/resolved/ignored counts - Resolve modal with product search - Bulk resolve option for same GTIN - Ignore functionality Files: - New: letzshop-exceptions-tab.html partial template - Updated: marketplace-letzshop.html (tab button, panel, resolve modal) - Updated: marketplace-letzshop.js (exception state, methods) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/templates/admin/marketplace-letzshop.html | 146 +++++++++++ .../partials/letzshop-exceptions-tab.html | 234 ++++++++++++++++++ static/admin/js/marketplace-letzshop.js | 188 +++++++++++++- 3 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 app/templates/admin/partials/letzshop-exceptions-tab.html diff --git a/app/templates/admin/marketplace-letzshop.html b/app/templates/admin/marketplace-letzshop.html index 55366b59..8e6b44d1 100644 --- a/app/templates/admin/marketplace-letzshop.html +++ b/app/templates/admin/marketplace-letzshop.html @@ -119,6 +119,7 @@ {% call tabs_nav(tab_var='activeTab') %} {{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }} {{ tab_button('orders', 'Orders', tab_var='activeTab', icon='shopping-cart', count_var='orderStats.pending') }} + {{ tab_button('exceptions', 'Exceptions', tab_var='activeTab', icon='exclamation-circle', count_var='exceptionStats.pending') }} {{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }} {% endcall %} @@ -137,6 +138,11 @@ {% include 'admin/partials/letzshop-settings-tab.html' %} {{ endtab_panel() }} + + {{ tab_panel('exceptions', tab_var='activeTab') }} + {% include 'admin/partials/letzshop-exceptions-tab.html' %} + {{ endtab_panel() }} +
{% include 'admin/partials/letzshop-jobs-table.html' %} @@ -426,6 +432,146 @@
+ + +
+
+
+

Resolve Exception

+ +
+ + +
+

+
+

+ GTIN: + +

+

+ SKU: +

+

+ Order: + +

+
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+
+

+

+
+ +
+
+ + +
+ + +
+ + +
+ +
+ +
+ + +
+
+
+
{% endblock %} {% block extra_scripts %} diff --git a/app/templates/admin/partials/letzshop-exceptions-tab.html b/app/templates/admin/partials/letzshop-exceptions-tab.html new file mode 100644 index 00000000..b6ce83bd --- /dev/null +++ b/app/templates/admin/partials/letzshop-exceptions-tab.html @@ -0,0 +1,234 @@ +{# app/templates/admin/partials/letzshop-exceptions-tab.html #} +{# Exceptions tab for admin Letzshop management - Order Item Exception Resolution #} + + +
+
+

Product Exceptions

+

Resolve unmatched products from order imports

+
+ +
+ + +
+ +
+
+ +
+
+

Pending

+

+
+
+ + +
+
+ +
+
+

Resolved

+

+
+
+ + +
+
+ +
+
+

Ignored

+

+
+
+ + +
+
+ +
+
+

Orders Affected

+

+
+
+
+ + +
+ +
+ + + +
+ + + +
+ + +
+
+ + + + + + + + + + + + + + + + +
Product InfoGTINOrderStatusCreatedActions
+
+ +
+ + Showing - of + + + + + +
+
diff --git a/static/admin/js/marketplace-letzshop.js b/static/admin/js/marketplace-letzshop.js index 6bd624c1..290c07c3 100644 --- a/static/admin/js/marketplace-letzshop.js +++ b/static/admin/js/marketplace-letzshop.js @@ -94,6 +94,16 @@ function adminMarketplaceLetzshop() { ordersHasDeclinedItems: false, orderStats: { pending: 0, processing: 0, shipped: 0, delivered: 0, cancelled: 0, total: 0, has_declined_items: 0 }, + // Exceptions + exceptions: [], + totalExceptions: 0, + exceptionsPage: 1, + exceptionsLimit: 20, + exceptionsFilter: '', + exceptionsSearch: '', + exceptionStats: { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 }, + loadingExceptions: false, + // Jobs jobs: [], jobsFilter: { type: '', status: '' }, @@ -102,8 +112,15 @@ function adminMarketplaceLetzshop() { // Modals showTrackingModal: false, showOrderModal: false, + showResolveModal: false, selectedOrder: null, + selectedExceptionForResolve: null, trackingForm: { tracking_number: '', tracking_provider: '' }, + resolveForm: { product_id: null, product_name: '', notes: '', bulk_resolve: false }, + productSearchQuery: '', + productSearchResults: [], + searchingProducts: false, + submittingResolve: false, async init() { marketplaceLetzshopLog.info('init() called'); @@ -208,9 +225,11 @@ function adminMarketplaceLetzshop() { // Load Letzshop status and credentials await this.loadLetzshopStatus(); - // Load orders and jobs + // Load orders, exceptions, and jobs await Promise.all([ this.loadOrders(), + this.loadExceptions(), + this.loadExceptionStats(), this.loadJobs() ]); @@ -234,6 +253,10 @@ function adminMarketplaceLetzshop() { this.ordersFilter = ''; this.ordersSearch = ''; this.ordersHasDeclinedItems = false; + this.exceptions = []; + this.exceptionsFilter = ''; + this.exceptionsSearch = ''; + this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 }; this.jobs = []; this.settingsForm = { api_key: '', @@ -927,6 +950,169 @@ function adminMarketplaceLetzshop() { } }, + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // EXCEPTIONS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + /** + * Load exceptions for selected vendor + */ + async loadExceptions() { + if (!this.selectedVendor) { + this.exceptions = []; + this.totalExceptions = 0; + return; + } + + this.loadingExceptions = true; + + try { + const params = new URLSearchParams({ + skip: ((this.exceptionsPage - 1) * this.exceptionsLimit).toString(), + limit: this.exceptionsLimit.toString() + }); + + if (this.exceptionsFilter) { + params.append('status', this.exceptionsFilter); + } + + if (this.exceptionsSearch) { + params.append('search', this.exceptionsSearch); + } + + const response = await apiClient.get(`/admin/order-exceptions?vendor_id=${this.selectedVendor.id}&${params}`); + this.exceptions = response.exceptions || []; + this.totalExceptions = response.total || 0; + } 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 + */ + async loadExceptionStats() { + if (!this.selectedVendor) { + this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 }; + return; + } + + try { + const response = await apiClient.get(`/admin/order-exceptions/stats?vendor_id=${this.selectedVendor.id}`); + this.exceptionStats = response; + } catch (error) { + marketplaceLetzshopLog.error('Failed to load exception stats:', error); + } + }, + + /** + * 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 // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•