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 %}