fix: Letzshop page improvements - products, jobs, and tabs
1. Products tab now shows Letzshop marketplace products instead of vendor products - Uses /admin/products endpoint with marketplace=Letzshop filter - Fixed field names (image_link, price_numeric, sku vs vendor_sku) - Search now works with title, GTIN, SKU, brand 2. Jobs section moved to a separate tab - New "Jobs" tab between Exceptions and Settings - Tab watcher reloads data when switching tabs - Updated filter dropdown (removed export, added historical_import) 3. Product stats endpoint now accepts marketplace and vendor_name filters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -190,11 +190,15 @@ def get_products(
|
|||||||
|
|
||||||
@router.get("/stats", response_model=AdminProductStats)
|
@router.get("/stats", response_model=AdminProductStats)
|
||||||
def get_product_stats(
|
def get_product_stats(
|
||||||
|
marketplace: str | None = Query(None, description="Filter by marketplace"),
|
||||||
|
vendor_name: str | None = Query(None, description="Filter by vendor name"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
):
|
):
|
||||||
"""Get product statistics for admin dashboard."""
|
"""Get product statistics for admin dashboard."""
|
||||||
stats = marketplace_product_service.get_admin_product_stats(db)
|
stats = marketplace_product_service.get_admin_product_stats(
|
||||||
|
db, marketplace=marketplace, vendor_name=vendor_name
|
||||||
|
)
|
||||||
return AdminProductStats(**stats)
|
return AdminProductStats(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -681,36 +681,59 @@ class MarketplaceProductService:
|
|||||||
|
|
||||||
return result, total
|
return result, total
|
||||||
|
|
||||||
def get_admin_product_stats(self, db: Session) -> dict:
|
def get_admin_product_stats(
|
||||||
"""Get product statistics for admin dashboard."""
|
self,
|
||||||
|
db: Session,
|
||||||
|
marketplace: str | None = None,
|
||||||
|
vendor_name: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Get product statistics for admin dashboard.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
marketplace: Optional filter by marketplace (e.g., 'Letzshop')
|
||||||
|
vendor_name: Optional filter by vendor name
|
||||||
|
"""
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
total = db.query(func.count(MarketplaceProduct.id)).scalar() or 0
|
# Build base filter
|
||||||
|
base_filters = []
|
||||||
|
if marketplace:
|
||||||
|
base_filters.append(MarketplaceProduct.marketplace == marketplace)
|
||||||
|
if vendor_name:
|
||||||
|
base_filters.append(MarketplaceProduct.vendor_name == vendor_name)
|
||||||
|
|
||||||
active = (
|
base_query = db.query(func.count(MarketplaceProduct.id))
|
||||||
db.query(func.count(MarketplaceProduct.id))
|
if base_filters:
|
||||||
.filter(MarketplaceProduct.is_active == True) # noqa: E712
|
base_query = base_query.filter(*base_filters)
|
||||||
.scalar()
|
|
||||||
or 0
|
total = base_query.scalar() or 0
|
||||||
|
|
||||||
|
active_query = db.query(func.count(MarketplaceProduct.id)).filter(
|
||||||
|
MarketplaceProduct.is_active == True # noqa: E712
|
||||||
)
|
)
|
||||||
|
if base_filters:
|
||||||
|
active_query = active_query.filter(*base_filters)
|
||||||
|
active = active_query.scalar() or 0
|
||||||
inactive = total - active
|
inactive = total - active
|
||||||
|
|
||||||
digital = (
|
digital_query = db.query(func.count(MarketplaceProduct.id)).filter(
|
||||||
db.query(func.count(MarketplaceProduct.id))
|
MarketplaceProduct.is_digital == True # noqa: E712
|
||||||
.filter(MarketplaceProduct.is_digital == True) # noqa: E712
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
|
if base_filters:
|
||||||
|
digital_query = digital_query.filter(*base_filters)
|
||||||
|
digital = digital_query.scalar() or 0
|
||||||
physical = total - digital
|
physical = total - digital
|
||||||
|
|
||||||
marketplace_counts = (
|
marketplace_query = db.query(
|
||||||
db.query(
|
MarketplaceProduct.marketplace,
|
||||||
MarketplaceProduct.marketplace,
|
func.count(MarketplaceProduct.id),
|
||||||
func.count(MarketplaceProduct.id),
|
|
||||||
)
|
|
||||||
.group_by(MarketplaceProduct.marketplace)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
if base_filters:
|
||||||
|
marketplace_query = marketplace_query.filter(*base_filters)
|
||||||
|
marketplace_counts = marketplace_query.group_by(
|
||||||
|
MarketplaceProduct.marketplace
|
||||||
|
).all()
|
||||||
by_marketplace = {mp or "unknown": count for mp, count in marketplace_counts}
|
by_marketplace = {mp or "unknown": count for mp, count in marketplace_counts}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -127,6 +127,9 @@
|
|||||||
</template>
|
</template>
|
||||||
{{ tab_button('orders', 'Orders', tab_var='activeTab', icon='shopping-cart', count_var='orderStats.pending') }}
|
{{ 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('exceptions', 'Exceptions', tab_var='activeTab', icon='exclamation-circle', count_var='exceptionStats.pending') }}
|
||||||
|
<template x-if="selectedVendor">
|
||||||
|
<span>{{ tab_button('jobs', 'Jobs', tab_var='activeTab', icon='collection') }}</span>
|
||||||
|
</template>
|
||||||
<template x-if="selectedVendor">
|
<template x-if="selectedVendor">
|
||||||
<span>{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }}</span>
|
<span>{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -156,10 +159,12 @@
|
|||||||
{% include 'admin/partials/letzshop-exceptions-tab.html' %}
|
{% include 'admin/partials/letzshop-exceptions-tab.html' %}
|
||||||
{{ endtab_panel() }}
|
{{ endtab_panel() }}
|
||||||
|
|
||||||
<!-- Unified Jobs Table (below all tabs) - Vendor only -->
|
<!-- Jobs Tab - Vendor only -->
|
||||||
<div x-show="selectedVendor" class="mt-8">
|
<template x-if="selectedVendor">
|
||||||
{% include 'admin/partials/letzshop-jobs-table.html' %}
|
{{ tab_panel('jobs', tab_var='activeTab') }}
|
||||||
</div>
|
{% include 'admin/partials/letzshop-jobs-table.html' %}
|
||||||
|
{{ endtab_panel() }}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tracking Modal -->
|
<!-- Tracking Modal -->
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
>
|
>
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="import">Product Import</option>
|
<option value="import">Product Import</option>
|
||||||
<option value="export">Product Export</option>
|
<option value="historical_import">Historical Order Import</option>
|
||||||
<option value="order_sync">Order Sync</option>
|
<option value="order_sync">Order Sync</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
@@ -87,14 +87,14 @@
|
|||||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300': job.type === 'import',
|
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300': job.type === 'import',
|
||||||
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': job.type === 'export',
|
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300': job.type === 'historical_import',
|
||||||
'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300': job.type === 'order_sync'
|
'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300': job.type === 'order_sync'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span x-show="job.type === 'import'" x-html="$icon('cloud-download', 'inline w-3 h-3 mr-1')"></span>
|
<span x-show="job.type === 'import'" x-html="$icon('cloud-download', 'inline w-3 h-3 mr-1')"></span>
|
||||||
<span x-show="job.type === 'export'" x-html="$icon('upload', 'inline w-3 h-3 mr-1')"></span>
|
<span x-show="job.type === 'historical_import'" x-html="$icon('clock', 'inline w-3 h-3 mr-1')"></span>
|
||||||
<span x-show="job.type === 'order_sync'" x-html="$icon('refresh', 'inline w-3 h-3 mr-1')"></span>
|
<span x-show="job.type === 'order_sync'" x-html="$icon('refresh', 'inline w-3 h-3 mr-1')"></span>
|
||||||
<span x-text="job.type === 'import' ? 'Import' : job.type === 'export' ? 'Export' : 'Order Sync'"></span>
|
<span x-text="job.type === 'import' ? 'Product Import' : job.type === 'historical_import' ? 'Historical Import' : 'Order Sync'"></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
x-show="job.type === 'import' && (job.status === 'failed' || job.status === 'completed_with_errors')"
|
x-show="(job.type === 'import' || job.type === 'historical_import') && (job.status === 'failed' || job.status === 'completed_with_errors')"
|
||||||
@click="viewJobErrors(job)"
|
@click="viewJobErrors(job)"
|
||||||
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
|
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
|
||||||
title="View Errors"
|
title="View Errors"
|
||||||
|
|||||||
@@ -166,10 +166,10 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<!-- Product Image -->
|
<!-- Product Image -->
|
||||||
<div class="w-10 h-10 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
|
<div class="w-10 h-10 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
|
||||||
<template x-if="product.image_url">
|
<template x-if="product.image_link">
|
||||||
<img :src="product.image_url" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
|
<img :src="product.image_link" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!product.image_url">
|
<template x-if="!product.image_link">
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
|
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Product Details -->
|
<!-- Product Details -->
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<a :href="'/admin/vendor-products/' + product.id" class="font-semibold text-sm truncate max-w-xs hover:text-purple-600 dark:hover:text-purple-400" x-text="product.title || 'Untitled'"></a>
|
<a :href="'/admin/marketplace-products/' + product.id" class="font-semibold text-sm truncate max-w-xs hover:text-purple-600 dark:hover:text-purple-400" x-text="product.title || 'Untitled'"></a>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
|
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,10 +189,10 @@
|
|||||||
<template x-if="product.gtin">
|
<template x-if="product.gtin">
|
||||||
<p class="text-xs"><span class="text-gray-500">GTIN:</span> <span x-text="product.gtin" class="font-mono"></span></p>
|
<p class="text-xs"><span class="text-gray-500">GTIN:</span> <span x-text="product.gtin" class="font-mono"></span></p>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="product.vendor_sku">
|
<template x-if="product.sku">
|
||||||
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.vendor_sku" class="font-mono"></span></p>
|
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.sku" class="font-mono"></span></p>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!product.gtin && !product.vendor_sku">
|
<template x-if="!product.gtin && !product.sku">
|
||||||
<p class="text-xs text-gray-400">No identifiers</p>
|
<p class="text-xs text-gray-400">No identifiers</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,10 +200,10 @@
|
|||||||
|
|
||||||
<!-- Price -->
|
<!-- Price -->
|
||||||
<td class="px-4 py-3 text-sm">
|
<td class="px-4 py-3 text-sm">
|
||||||
<template x-if="product.effective_price">
|
<template x-if="product.price_numeric">
|
||||||
<p class="font-medium" x-text="formatPrice(product.effective_price, product.effective_currency || 'EUR')"></p>
|
<p class="font-medium" x-text="formatPrice(product.price_numeric, product.currency || 'EUR')"></p>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!product.effective_price">
|
<template x-if="!product.price_numeric">
|
||||||
<p class="text-gray-400">-</p>
|
<p class="text-gray-400">-</p>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
<td class="px-4 py-3 text-sm">
|
<td class="px-4 py-3 text-sm">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<a
|
<a
|
||||||
:href="'/admin/vendor-products/' + product.id"
|
:href="'/admin/marketplace-products/' + product.id"
|
||||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
title="View Details"
|
title="View Details"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -153,6 +153,20 @@ function adminMarketplaceLetzshop() {
|
|||||||
this.initTomSelect();
|
this.initTomSelect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch for tab changes to reload relevant data
|
||||||
|
this.$watch('activeTab', async (newTab) => {
|
||||||
|
marketplaceLetzshopLog.info('Tab changed to:', newTab);
|
||||||
|
if (newTab === 'jobs' && this.selectedVendor) {
|
||||||
|
await this.loadJobs();
|
||||||
|
} else if (newTab === 'products' && this.selectedVendor) {
|
||||||
|
await this.loadProducts();
|
||||||
|
} else if (newTab === 'orders') {
|
||||||
|
await this.loadOrders();
|
||||||
|
} else if (newTab === 'exceptions') {
|
||||||
|
await Promise.all([this.loadExceptions(), this.loadExceptionStats()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check localStorage for last selected vendor
|
// Check localStorage for last selected vendor
|
||||||
const savedVendorId = localStorage.getItem('letzshop_selected_vendor_id');
|
const savedVendorId = localStorage.getItem('letzshop_selected_vendor_id');
|
||||||
if (savedVendorId) {
|
if (savedVendorId) {
|
||||||
@@ -408,11 +422,12 @@ function adminMarketplaceLetzshop() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// PRODUCTS TAB - LISTING
|
// PRODUCTS TAB - LISTING (Letzshop marketplace products)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load products for selected vendor
|
* Load Letzshop products for selected vendor
|
||||||
|
* Uses /admin/products endpoint with marketplace=Letzshop filter
|
||||||
*/
|
*/
|
||||||
async loadProducts() {
|
async loadProducts() {
|
||||||
if (!this.selectedVendor) {
|
if (!this.selectedVendor) {
|
||||||
@@ -426,7 +441,8 @@ function adminMarketplaceLetzshop() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
vendor_id: this.selectedVendor.id.toString(),
|
marketplace: 'Letzshop',
|
||||||
|
vendor_name: this.selectedVendor.name,
|
||||||
skip: ((this.productsPage - 1) * this.productsLimit).toString(),
|
skip: ((this.productsPage - 1) * this.productsLimit).toString(),
|
||||||
limit: this.productsLimit.toString()
|
limit: this.productsLimit.toString()
|
||||||
});
|
});
|
||||||
@@ -439,17 +455,12 @@ function adminMarketplaceLetzshop() {
|
|||||||
params.append('is_active', this.productFilters.is_active);
|
params.append('is_active', this.productFilters.is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.get(`/admin/vendor-products?${params}`);
|
const response = await apiClient.get(`/admin/products?${params}`);
|
||||||
this.products = response.products || [];
|
this.products = response.products || [];
|
||||||
this.totalProducts = response.total || 0;
|
this.totalProducts = response.total || 0;
|
||||||
|
|
||||||
// Update stats
|
// Load stats separately
|
||||||
if (response.stats) {
|
await this.loadProductStats();
|
||||||
this.productStats = response.stats;
|
|
||||||
} else {
|
|
||||||
// Calculate from response if not provided
|
|
||||||
await this.loadProductStats();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
marketplaceLetzshopLog.error('Failed to load products:', error);
|
marketplaceLetzshopLog.error('Failed to load products:', error);
|
||||||
this.products = [];
|
this.products = [];
|
||||||
@@ -460,18 +471,22 @@ function adminMarketplaceLetzshop() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load product statistics
|
* Load product statistics for Letzshop products
|
||||||
*/
|
*/
|
||||||
async loadProductStats() {
|
async loadProductStats() {
|
||||||
if (!this.selectedVendor) return;
|
if (!this.selectedVendor) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/vendor-products/stats?vendor_id=${this.selectedVendor.id}`);
|
const params = new URLSearchParams({
|
||||||
|
marketplace: 'Letzshop',
|
||||||
|
vendor_name: this.selectedVendor.name
|
||||||
|
});
|
||||||
|
const response = await apiClient.get(`/admin/products/stats?${params}`);
|
||||||
this.productStats = {
|
this.productStats = {
|
||||||
total: response.total || 0,
|
total: response.total || 0,
|
||||||
active: response.active || 0,
|
active: response.active || 0,
|
||||||
inactive: response.inactive || 0,
|
inactive: response.inactive || 0,
|
||||||
last_sync: response.last_sync || null
|
last_sync: null // TODO: Get from last import job
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
marketplaceLetzshopLog.error('Failed to load product stats:', error);
|
marketplaceLetzshopLog.error('Failed to load product stats:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user