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

@@ -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() }}
<!-- Exceptions Tab -->
{{ tab_panel('exceptions', tab_var='activeTab') }}
{% include 'admin/partials/letzshop-exceptions-tab.html' %}
{{ endtab_panel() }}
<!-- Unified Jobs Table (below all tabs) -->
<div class="mt-8">
{% include 'admin/partials/letzshop-jobs-table.html' %}
@@ -426,6 +432,146 @@
</div>
</div>
</div>
<!-- Exception Resolve Modal -->
<div
x-show="showResolveModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showResolveModal = false"
x-cloak
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-lg"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Resolve Exception</h3>
<button @click="showResolveModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<!-- Exception Details -->
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="selectedExceptionForResolve?.original_product_name || 'Unknown Product'"></p>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p x-show="selectedExceptionForResolve?.original_gtin">
<span class="font-medium">GTIN:</span>
<code class="ml-1 px-1 bg-gray-200 dark:bg-gray-600 rounded" x-text="selectedExceptionForResolve?.original_gtin"></code>
</p>
<p x-show="selectedExceptionForResolve?.original_sku">
<span class="font-medium">SKU:</span> <span x-text="selectedExceptionForResolve?.original_sku"></span>
</p>
<p>
<span class="font-medium">Order:</span>
<a :href="'/admin/orders/' + selectedExceptionForResolve?.order_id" class="text-purple-600 hover:underline" x-text="selectedExceptionForResolve?.order_number"></a>
</p>
</div>
</div>
<form @submit.prevent="submitResolveException()">
<!-- Product Search -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Assign Product <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
type="text"
x-model="productSearchQuery"
@input.debounce.300ms="searchProducts()"
placeholder="Search by name, SKU, or GTIN..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<span x-show="searchingProducts" x-html="$icon('spinner', 'w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
</div>
<!-- Search Results -->
<div x-show="productSearchResults.length > 0" class="mt-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-md">
<template x-for="product in productSearchResults" :key="product.id">
<button
type="button"
@click="selectProductForResolve(product)"
class="w-full px-3 py-2 text-left text-sm hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-gray-100 dark:border-gray-700 last:border-b-0"
:class="resolveForm.product_id === product.id ? 'bg-purple-100 dark:bg-purple-900/30' : ''"
>
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="product.name || product.title"></p>
<p class="text-xs text-gray-500">
<span x-show="product.gtin" x-text="'GTIN: ' + product.gtin"></span>
<span x-show="product.gtin && product.sku"> · </span>
<span x-show="product.sku" x-text="'SKU: ' + product.sku"></span>
</p>
</button>
</template>
</div>
<!-- Selected Product -->
<div x-show="resolveForm.product_id" class="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded-md flex items-center justify-between">
<div>
<p class="text-sm font-medium text-green-700 dark:text-green-300" x-text="resolveForm.product_name"></p>
<p class="text-xs text-green-600 dark:text-green-400" x-text="'Product ID: ' + resolveForm.product_id"></p>
</div>
<button type="button" @click="resolveForm.product_id = null; resolveForm.product_name = ''" class="text-green-600 hover:text-green-800">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</div>
<!-- Resolution Notes -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Notes (Optional)
</label>
<textarea
x-model="resolveForm.notes"
rows="2"
placeholder="Add any notes about this resolution..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
></textarea>
</div>
<!-- Bulk Resolve Option -->
<div x-show="selectedExceptionForResolve?.original_gtin" class="mb-4">
<label class="flex items-center text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
x-model="resolveForm.bulk_resolve"
class="mr-2 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
Resolve all pending exceptions with this GTIN
</label>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
@click="showResolveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
:disabled="!resolveForm.product_id || submittingResolve"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50"
>
<span x-show="submittingResolve" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="submittingResolve ? 'Resolving...' : 'Resolve Exception'"></span>
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}

View File

@@ -0,0 +1,234 @@
{# app/templates/admin/partials/letzshop-exceptions-tab.html #}
{# Exceptions tab for admin Letzshop management - Order Item Exception Resolution #}
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Product Exceptions</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Resolve unmatched products from order imports</p>
</div>
<button
@click="loadExceptions()"
:disabled="loadingExceptions"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
<span x-show="!loadingExceptions" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loadingExceptions" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Pending Exceptions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
<span x-html="$icon('exclamation-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.pending || 0"></p>
</div>
</div>
<!-- Resolved Exceptions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Resolved</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.resolved || 0"></p>
</div>
</div>
<!-- Ignored Exceptions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:bg-gray-700">
<span x-html="$icon('ban', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Ignored</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.ignored || 0"></p>
</div>
</div>
<!-- Orders Affected -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:bg-purple-900">
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Orders Affected</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.orders_with_exceptions || 0"></p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap gap-4 items-center">
<!-- Search input -->
<div class="relative flex-1 min-w-[200px] max-w-md">
<span x-html="$icon('search', 'w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
<input
type="text"
x-model="exceptionsSearch"
@input.debounce.300ms="exceptionsPage = 1; loadExceptions()"
placeholder="Search by GTIN, product name, or order..."
class="w-full pl-9 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<button
x-show="exceptionsSearch"
@click="exceptionsSearch = ''; exceptionsPage = 1; loadExceptions()"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
<!-- Status filter -->
<select
x-model="exceptionsFilter"
@change="exceptionsPage = 1; loadExceptions()"
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="ignored">Ignored</option>
</select>
</div>
<!-- Exceptions Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product Info</th>
<th class="px-4 py-3">GTIN</th>
<th class="px-4 py-3">Order</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingExceptions && exceptions.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading exceptions...</p>
</td>
</tr>
</template>
<template x-if="!loadingExceptions && exceptions.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('check-circle', 'w-12 h-12 mx-auto mb-2 text-green-300')"></span>
<p class="font-medium">No exceptions found</p>
<p class="text-sm mt-1">All order items are properly matched to products</p>
</td>
</tr>
</template>
<template x-for="exc in exceptions" :key="exc.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="exc.original_product_name || 'Unknown Product'"></p>
<p class="text-xs text-gray-500" x-show="exc.original_sku" x-text="'SKU: ' + exc.original_sku"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="exc.original_gtin || 'No GTIN'"></code>
</td>
<td class="px-4 py-3 text-sm">
<a
:href="'/admin/orders/' + exc.order_id"
class="text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="exc.order_number"
></a>
<p class="text-xs text-gray-500" x-text="formatDate(exc.order_date)"></p>
</td>
<td class="px-4 py-3 text-xs">
<span
class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': exc.status === 'pending',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': exc.status === 'resolved',
'text-gray-700 bg-gray-100 dark:bg-gray-600 dark:text-gray-100': exc.status === 'ignored'
}"
x-text="exc.status.toUpperCase()"
></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="formatDate(exc.created_at)"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<template x-if="exc.status === 'pending'">
<button
@click="openResolveModal(exc)"
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
title="Resolve - Assign Product"
>
<span x-html="$icon('check', 'w-4 h-4 mr-1')"></span>
Resolve
</button>
</template>
<template x-if="exc.status === 'pending'">
<button
@click="ignoreException(exc)"
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
title="Ignore Exception"
>
<span x-html="$icon('ban', 'w-4 h-4')"></span>
</button>
</template>
<template x-if="exc.status === 'resolved'">
<span class="text-xs text-gray-500">
<span x-html="$icon('check', 'w-3 h-3 inline mr-1')"></span>
Resolved
</span>
</template>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div x-show="totalExceptions > exceptionsLimit" class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing <span x-text="((exceptionsPage - 1) * exceptionsLimit) + 1" class="mx-1"></span>-<span x-text="Math.min(exceptionsPage * exceptionsLimit, totalExceptions)" class="mx-1"></span> of <span x-text="totalExceptions" class="mx-1"></span>
</span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<li>
<button
@click="exceptionsPage--; loadExceptions()"
:disabled="exceptionsPage <= 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<li>
<button
@click="exceptionsPage++; loadExceptions()"
:disabled="exceptionsPage * exceptionsLimit >= totalExceptions"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</li>
</ul>
</nav>
</span>
</div>
</div>

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