feat: add Exceptions tab to Letzshop management page

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 14:09:57 +01:00
parent eb57df3bfb
commit 7f0d32c18d
3 changed files with 567 additions and 1 deletions

View File

@@ -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
// ═══════════════════════════════════════════════════════════════