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:
@@ -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 %}
|
||||
|
||||
234
app/templates/admin/partials/letzshop-exceptions-tab.html
Normal file
234
app/templates/admin/partials/letzshop-exceptions-tab.html
Normal 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>
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user