feat: redesign Letzshop products tab with product listing view

Products Tab Changes:
- Converted to product listing page similar to /admin/marketplace-products
- Added Import/Export buttons in header
- Added product stats cards (total, active, inactive, last sync)
- Added search and filter functionality
- Added product table with pagination
- Import modal for single URL or all languages

Settings Tab Changes:
- Moved batch size setting from products tab
- Moved include inactive checkbox from products tab
- Added export behavior info box

Export Changes:
- New POST endpoint exports all languages (FR, DE, EN)
- CSV files written to exports/letzshop/{vendor_code}/ for scheduler pickup
- Letzshop scheduler can fetch files from this location

API Changes:
- Added vendor_id filter to /admin/vendor-products/stats endpoint
- Added POST /admin/vendors/{id}/export/letzshop for folder export

🤖 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 21:44:59 +01:00
parent 44c11181fd
commit d46b676e77
6 changed files with 692 additions and 258 deletions

View File

@@ -206,11 +206,12 @@ def get_vendor_products(
@router.get("/stats", response_model=VendorProductStats)
def get_vendor_product_stats(
vendor_id: int | None = Query(None, description="Filter stats by vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get vendor product statistics for admin dashboard."""
stats = vendor_product_service.get_product_stats(db)
stats = vendor_product_service.get_product_stats(db, vendor_id=vendor_id)
return VendorProductStats(**stats)

View File

@@ -363,3 +363,83 @@ def export_vendor_products_letzshop(
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
class LetzshopExportRequest(BaseModel):
"""Request body for Letzshop export to pickup folder."""
include_inactive: bool = False
@router.post("/{vendor_identifier}/export/letzshop")
def export_vendor_products_letzshop_to_folder(
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
request: LetzshopExportRequest = None,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Export vendor products to Letzshop pickup folder (Admin only).
Generates CSV files for all languages (FR, DE, EN) and places them in a folder
that Letzshop scheduler can fetch from. This is the preferred method for
automated product sync.
**Behavior:**
- Creates CSV files for each language (fr, de, en)
- Places files in: exports/letzshop/{vendor_code}/
- Filename format: {vendor_code}_products_{language}.csv
Returns:
JSON with export status and file paths
"""
import os
from pathlib import Path as FilePath
from app.services.letzshop_export_service import letzshop_export_service
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
include_inactive = request.include_inactive if request else False
# Create export directory
export_dir = FilePath(f"exports/letzshop/{vendor.vendor_code.lower()}")
export_dir.mkdir(parents=True, exist_ok=True)
exported_files = []
languages = ["fr", "de", "en"]
for lang in languages:
try:
csv_content = letzshop_export_service.export_vendor_products(
db=db,
vendor_id=vendor.id,
language=lang,
include_inactive=include_inactive,
)
filename = f"{vendor.vendor_code.lower()}_products_{lang}.csv"
filepath = export_dir / filename
with open(filepath, "w", encoding="utf-8") as f:
f.write(csv_content)
exported_files.append({
"language": lang,
"filename": filename,
"path": str(filepath),
"size_bytes": os.path.getsize(filepath),
})
except Exception as e:
exported_files.append({
"language": lang,
"error": str(e),
})
return {
"success": True,
"message": f"Exported {len([f for f in exported_files if 'error' not in f])} language(s) to {export_dir}",
"vendor_code": vendor.vendor_code,
"export_directory": str(export_dir),
"files": exported_files,
}

View File

@@ -75,12 +75,24 @@ class VendorProductService:
return result, total
def get_product_stats(self, db: Session) -> dict:
"""Get vendor product statistics for admin dashboard."""
total = db.query(func.count(Product.id)).scalar() or 0
def get_product_stats(self, db: Session, vendor_id: int | None = None) -> dict:
"""Get vendor product statistics for admin dashboard.
Args:
db: Database session
vendor_id: Optional vendor ID to filter stats
Returns:
Dict with product counts (total, active, inactive, etc.)
"""
# Base query filter
base_filter = Product.vendor_id == vendor_id if vendor_id else True
total = db.query(func.count(Product.id)).filter(base_filter).scalar() or 0
active = (
db.query(func.count(Product.id))
.filter(base_filter)
.filter(Product.is_active == True) # noqa: E712
.scalar()
or 0
@@ -89,6 +101,7 @@ class VendorProductService:
featured = (
db.query(func.count(Product.id))
.filter(base_filter)
.filter(Product.is_featured == True) # noqa: E712
.scalar()
or 0
@@ -97,6 +110,7 @@ class VendorProductService:
# Digital/physical counts
digital = (
db.query(func.count(Product.id))
.filter(base_filter)
.join(Product.marketplace_product)
.filter(Product.marketplace_product.has(is_digital=True))
.scalar()
@@ -104,17 +118,19 @@ class VendorProductService:
)
physical = total - digital
# Count by vendor
vendor_counts = (
db.query(
Vendor.name,
func.count(Product.id),
# Count by vendor (only when not filtered by vendor_id)
by_vendor = {}
if not vendor_id:
vendor_counts = (
db.query(
Vendor.name,
func.count(Product.id),
)
.join(Vendor, Product.vendor_id == Vendor.id)
.group_by(Vendor.name)
.all()
)
.join(Vendor, Product.vendor_id == Vendor.id)
.group_by(Vendor.name)
.all()
)
by_vendor = {name or "unknown": count for name, count in vendor_counts}
by_vendor = {name or "unknown": count for name, count in vendor_counts}
return {
"total": total,

View File

@@ -1,236 +1,382 @@
{# app/templates/admin/partials/letzshop-products-tab.html #}
{# Products tab for admin Letzshop management - Import & Export #}
{% from 'shared/macros/inputs.html' import number_stepper %}
{# Products tab for admin Letzshop management - Product listing with Import/Export #}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/tables.html' import table_wrapper %}
<div class="grid gap-6 md:grid-cols-2">
<!-- Import Section -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Import Products from Letzshop
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Import products from a Letzshop CSV feed into the marketplace catalog.
<!-- Header with Import/Export Buttons -->
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Letzshop Products</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Vendor products synced with Letzshop marketplace</p>
</div>
<div class="flex items-center gap-3">
<!-- Import Button -->
<button
@click="showImportModal = true"
:disabled="importing"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-show="!importing" x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Importing...' : 'Import'"></span>
</button>
<!-- Export Button -->
<button
@click="exportAllLanguages()"
:disabled="exporting"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
<span x-show="!exporting" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="exporting ? 'Exporting...' : 'Export'"></span>
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 mb-6 md:grid-cols-2 xl:grid-cols-4">
<!-- Total Products -->
<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:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Products</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.total || 0"></p>
</div>
</div>
<!-- Active Products -->
<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:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.active || 0"></p>
</div>
</div>
<!-- Inactive Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Inactive</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.inactive || 0"></p>
</div>
</div>
<!-- Last Sync -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('refresh', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Sync</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.last_sync || 'Never'"></p>
</div>
</div>
</div>
<!-- Search and Filters Bar -->
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-xl">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="productFilters.search"
@input.debounce.300ms="loadProducts()"
placeholder="Search by title, GTIN, or SKU..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Status Filter -->
<select
x-model="productFilters.is_active"
@change="productsPage = 1; loadProducts()"
class="px-4 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-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Refresh Button -->
<button
@click="loadProducts()"
:disabled="loadingProducts"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Refresh products"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Loading State -->
<div x-show="loadingProducts" class="flex items-center justify-center py-12">
<span x-html="$icon('spinner', 'w-8 h-8 text-purple-600')"></span>
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading products...</span>
</div>
<!-- Products Table -->
<div x-show="!loadingProducts">
{% call table_wrapper() %}
<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</th>
<th class="px-4 py-3">Identifiers</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Status</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">
<!-- Empty State -->
<template x-if="products.length === 0">
<tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('cube', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No products found</p>
<p class="text-xs mt-1" x-text="productFilters.search ? 'Try adjusting your search' : 'Import products to get started'"></p>
</div>
</td>
</tr>
</template>
<!-- Product Rows -->
<template x-for="product in products" :key="product.id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Product Info -->
<td class="px-4 py-3">
<div class="flex items-center">
<!-- Product Image -->
<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">
<img :src="product.image_url" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
</template>
<template x-if="!product.image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Product Details -->
<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>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
</div>
</div>
</td>
<!-- Identifiers -->
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<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>
</template>
<template x-if="product.vendor_sku">
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.vendor_sku" class="font-mono"></span></p>
</template>
<template x-if="!product.gtin && !product.vendor_sku">
<p class="text-xs text-gray-400">No identifiers</p>
</template>
</div>
</td>
<!-- Price -->
<td class="px-4 py-3 text-sm">
<template x-if="product.effective_price">
<p class="font-medium" x-text="formatPrice(product.effective_price, product.effective_currency || 'EUR')"></p>
</template>
<template x-if="!product.effective_price">
<p class="text-gray-400">-</p>
</template>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="product.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="product.is_active ? 'Active' : 'Inactive'">
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<a
:href="'/admin/vendor-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"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
<!-- Pagination -->
<div x-show="totalProducts > productsLimit" class="mt-4">
<div class="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 dark:bg-gray-800 dark:border-gray-700 rounded-b-lg">
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<span>Showing</span>
<span class="font-medium mx-1" x-text="((productsPage - 1) * productsLimit) + 1"></span>
<span>to</span>
<span class="font-medium mx-1" x-text="Math.min(productsPage * productsLimit, totalProducts)"></span>
<span>of</span>
<span class="font-medium mx-1" x-text="totalProducts"></span>
<span>products</span>
</div>
<div class="flex gap-2">
<button
@click="productsPage--; loadProducts()"
:disabled="productsPage <= 1"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
@click="productsPage++; loadProducts()"
:disabled="productsPage * productsLimit >= totalProducts"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
<!-- Import Modal -->
<div
x-show="showImportModal"
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="showImportModal = 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">Import Products from Letzshop</h3>
<button @click="showImportModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Import products from Letzshop CSV feeds. All languages will be imported.
</p>
<!-- Quick Fill Buttons -->
<div class="mb-4" x-show="selectedVendor?.letzshop_csv_url_fr || selectedVendor?.letzshop_csv_url_en || selectedVendor?.letzshop_csv_url_de">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Quick Import
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="startImportAllLanguages()"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
Import All Languages
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Imports products from all configured CSV URLs (FR, EN, DE)
</p>
</div>
<form @submit.prevent="startImport()">
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 mt-4">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Or import from custom URL:</p>
<form @submit.prevent="startImportFromUrl()">
<!-- CSV URL -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
CSV URL <span class="text-red-500">*</span>
CSV URL
</label>
<input
x-model="importForm.csv_url"
type="url"
required
placeholder="https://letzshop.lu/feeds/products.csv"
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 focus:shadow-outline-purple"
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"
/>
</div>
<!-- Quick Fill Buttons -->
<div class="mb-4" x-show="selectedVendor?.letzshop_csv_url_fr || selectedVendor?.letzshop_csv_url_en || selectedVendor?.letzshop_csv_url_de">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Quick Fill
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="quickFillImport('fr')"
x-show="selectedVendor?.letzshop_csv_url_fr"
class="flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-md hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
>
<span class="fi fi-fr mr-1.5"></span>
French
</button>
<button
type="button"
@click="quickFillImport('en')"
x-show="selectedVendor?.letzshop_csv_url_en"
class="flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-md hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
>
<span class="fi fi-gb mr-1.5"></span>
English
</button>
<button
type="button"
@click="quickFillImport('de')"
x-show="selectedVendor?.letzshop_csv_url_de"
class="flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 border border-purple-300 dark:border-purple-700 rounded-md hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
>
<span class="fi fi-de mr-1.5"></span>
German
</button>
</div>
</div>
<!-- Language Selection -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Language
Language for this URL
</label>
<div class="flex flex-wrap gap-3">
<button
type="button"
@click="importForm.language = 'fr'"
:class="importForm.language === 'fr'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-fr"></span>
FR
<div class="flex gap-2">
<button type="button" @click="importForm.language = 'fr'"
:class="importForm.language === 'fr' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
<span class="fi fi-fr"></span> FR
</button>
<button
type="button"
@click="importForm.language = 'de'"
:class="importForm.language === 'de'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-de"></span>
DE
<button type="button" @click="importForm.language = 'de'"
:class="importForm.language === 'de' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
<span class="fi fi-de"></span> DE
</button>
<button
type="button"
@click="importForm.language = 'en'"
:class="importForm.language === 'en'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-gb"></span>
EN
<button type="button" @click="importForm.language = 'en'"
:class="importForm.language === 'en' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
<span class="fi fi-gb"></span> EN
</button>
</div>
</div>
<!-- Batch Size -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Batch Size
</label>
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Products processed per batch (100-5000)
</p>
<div class="flex justify-end gap-3">
<button
type="button"
@click="showImportModal = 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="importing || !importForm.csv_url"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Import
</button>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="importing || !importForm.csv_url"
class="w-full flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!importing" x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
</button>
</form>
</div>
</div>
<!-- Export Section -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Export Products to Letzshop
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Generate a Letzshop-compatible CSV file from this vendor's product catalog.
</p>
<!-- Language Selection -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Export Language
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select the language for product titles and descriptions
</p>
<div class="flex flex-wrap gap-3">
<button
@click="exportLanguage = 'fr'"
:class="exportLanguage === 'fr'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-fr"></span>
Francais
</button>
<button
@click="exportLanguage = 'de'"
:class="exportLanguage === 'de'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-de"></span>
Deutsch
</button>
<button
@click="exportLanguage = 'en'"
:class="exportLanguage === 'en'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-gb"></span>
English
</button>
</div>
</div>
<!-- Include Inactive -->
<div class="mb-6">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="exportIncludeInactive"
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
Export products that are currently marked as inactive
</p>
</div>
<!-- Download Button -->
<button
@click="downloadExport()"
:disabled="exporting"
class="w-full flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-show="!exporting" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="exporting ? 'Generating...' : 'Download CSV'"></span>
</button>
<!-- CSV Info -->
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV Format</h4>
<ul class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
Tab-separated values (TSV)
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
UTF-8 encoding
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
Google Shopping compatible
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
41 fields including price, stock, images
</li>
</ul>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
{# app/templates/admin/partials/letzshop-settings-tab.html #}
{# Settings tab for admin Letzshop management - API credentials and CSV URLs #}
{# Settings tab for admin Letzshop management - API credentials, CSV URLs, Import/Export settings #}
{% from 'shared/macros/inputs.html' import number_stepper %}
<div class="grid gap-6 lg:grid-cols-2">
<!-- API Configuration Card -->
@@ -236,6 +237,75 @@
</div>
</div>
<!-- Import/Export Settings Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Import / Export Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure settings for product import and export operations.
</p>
<!-- Import Settings -->
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2 text-purple-500')"></span>
Import Settings
</h4>
<div class="pl-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Batch Size
</label>
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Products processed per batch (100-5000). Higher = faster but more memory.
</p>
</div>
</div>
<!-- Export Settings -->
<div class="border-t border-gray-200 dark:border-gray-600 pt-6">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
<span x-html="$icon('upload', 'w-4 h-4 mr-2 text-green-500')"></span>
Export Settings
</h4>
<div class="pl-6">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="exportIncludeInactive"
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
Export products that are currently marked as inactive
</p>
</div>
</div>
<!-- Export Info Box -->
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Export Behavior</h4>
<ul class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
Exports all languages (FR, DE, EN) automatically
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
CSV files are placed in a folder for Letzshop pickup
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
Letzshop scheduler fetches files periodically
</li>
</ul>
</div>
</div>
</div>
<!-- Carrier Settings Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
<div class="p-6">

View File

@@ -115,6 +115,16 @@ function adminMarketplaceLetzshop() {
jobsFilter: { type: '', status: '' },
jobsPagination: { page: 1, per_page: 10, total: 0 },
// Products Tab
products: [],
totalProducts: 0,
productsPage: 1,
productsLimit: 20,
loadingProducts: false,
productFilters: { search: '', is_active: '' },
productStats: { total: 0, active: 0, inactive: 0, last_sync: null },
showImportModal: false,
// Modals
showTrackingModal: false,
showOrderModal: false,
@@ -300,11 +310,12 @@ function adminMarketplaceLetzshop() {
// Load Letzshop status and credentials
await this.loadLetzshopStatus();
// Load orders, exceptions, and jobs
// Load orders, exceptions, products, and jobs
await Promise.all([
this.loadOrders(),
this.loadExceptions(),
this.loadExceptionStats(),
this.loadProducts(),
this.loadJobs()
]);
@@ -397,49 +408,143 @@ function adminMarketplaceLetzshop() {
},
// ═══════════════════════════════════════════════════════════════
// PRODUCTS TAB - IMPORT
// PRODUCTS TAB - LISTING
// ═══════════════════════════════════════════════════════════════
/**
* Quick fill import form from vendor CSV URLs
* Load products for selected vendor
*/
quickFillImport(language) {
if (!this.selectedVendor) return;
async loadProducts() {
if (!this.selectedVendor) {
this.products = [];
this.totalProducts = 0;
this.productStats = { total: 0, active: 0, inactive: 0, last_sync: null };
return;
}
const urlMap = {
'fr': this.selectedVendor.letzshop_csv_url_fr,
'en': this.selectedVendor.letzshop_csv_url_en,
'de': this.selectedVendor.letzshop_csv_url_de
};
this.loadingProducts = true;
const url = urlMap[language];
if (url) {
this.importForm.csv_url = url;
this.importForm.language = language;
marketplaceLetzshopLog.info('Quick filled import form:', language, url);
try {
const params = new URLSearchParams({
vendor_id: this.selectedVendor.id.toString(),
skip: ((this.productsPage - 1) * this.productsLimit).toString(),
limit: this.productsLimit.toString()
});
if (this.productFilters.search) {
params.append('search', this.productFilters.search);
}
if (this.productFilters.is_active !== '') {
params.append('is_active', this.productFilters.is_active);
}
const response = await apiClient.get(`/admin/vendor-products?${params}`);
this.products = response.products || [];
this.totalProducts = response.total || 0;
// Update stats
if (response.stats) {
this.productStats = response.stats;
} else {
// Calculate from response if not provided
await this.loadProductStats();
}
} catch (error) {
marketplaceLetzshopLog.error('Failed to load products:', error);
this.products = [];
this.totalProducts = 0;
} finally {
this.loadingProducts = false;
}
},
/**
* Start product import
* Load product statistics
*/
async startImport() {
async loadProductStats() {
if (!this.selectedVendor) return;
try {
const response = await apiClient.get(`/admin/vendor-products/stats?vendor_id=${this.selectedVendor.id}`);
this.productStats = {
total: response.total || 0,
active: response.active || 0,
inactive: response.inactive || 0,
last_sync: response.last_sync || null
};
} catch (error) {
marketplaceLetzshopLog.error('Failed to load product stats:', error);
}
},
// ═══════════════════════════════════════════════════════════════
// PRODUCTS TAB - IMPORT
// ═══════════════════════════════════════════════════════════════
/**
* Import all languages from configured CSV URLs
*/
async startImportAllLanguages() {
if (!this.selectedVendor) return;
this.importing = true;
this.error = '';
this.successMessage = '';
this.showImportModal = false;
try {
const languages = [];
if (this.selectedVendor.letzshop_csv_url_fr) languages.push({ url: this.selectedVendor.letzshop_csv_url_fr, lang: 'fr' });
if (this.selectedVendor.letzshop_csv_url_en) languages.push({ url: this.selectedVendor.letzshop_csv_url_en, lang: 'en' });
if (this.selectedVendor.letzshop_csv_url_de) languages.push({ url: this.selectedVendor.letzshop_csv_url_de, lang: 'de' });
if (languages.length === 0) {
this.error = 'No CSV URLs configured. Please set them in Settings.';
this.importing = false;
return;
}
// Start import jobs for all languages
for (const { url, lang } of languages) {
await apiClient.post('/admin/marketplace-import-jobs', {
vendor_id: this.selectedVendor.id,
source_url: url,
marketplace: 'Letzshop',
language: lang,
batch_size: this.importForm.batch_size
});
}
this.successMessage = `Import started for ${languages.length} language(s)`;
await this.loadJobs();
} catch (error) {
marketplaceLetzshopLog.error('Failed to start import:', error);
this.error = error.message || 'Failed to start import';
} finally {
this.importing = false;
}
},
/**
* Import from custom URL
*/
async startImportFromUrl() {
if (!this.selectedVendor || !this.importForm.csv_url) return;
this.importing = true;
this.error = '';
this.successMessage = '';
this.showImportModal = false;
try {
const payload = {
await apiClient.post('/admin/marketplace-import-jobs', {
vendor_id: this.selectedVendor.id,
source_url: this.importForm.csv_url,
marketplace: 'Letzshop',
language: this.importForm.language,
batch_size: this.importForm.batch_size
};
await apiClient.post('/admin/marketplace-import-jobs', payload);
});
this.successMessage = 'Import job started successfully';
this.importForm.csv_url = '';
@@ -452,36 +557,34 @@ function adminMarketplaceLetzshop() {
}
},
/**
* Legacy method for backwards compatibility
*/
async startImport() {
return this.startImportFromUrl();
},
// ═══════════════════════════════════════════════════════════════
// PRODUCTS TAB - EXPORT
// ═══════════════════════════════════════════════════════════════
/**
* Download product export CSV
* Export products for all languages to Letzshop pickup folder
*/
async downloadExport() {
async exportAllLanguages() {
if (!this.selectedVendor) return;
this.exporting = true;
this.error = '';
this.successMessage = '';
try {
const params = new URLSearchParams({
language: this.exportLanguage,
include_inactive: this.exportIncludeInactive.toString()
const response = await apiClient.post(`/admin/vendors/${this.selectedVendor.id}/export/letzshop`, {
include_inactive: this.exportIncludeInactive
});
const url = `/api/v1/admin/vendors/${this.selectedVendor.id}/export/letzshop?${params}`;
// Create a link and trigger download
const link = document.createElement('a');
link.href = url;
link.download = `${this.selectedVendor.vendor_code}_letzshop_export.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.successMessage = 'Export started';
this.successMessage = response.message || 'Export completed. CSV files are ready for Letzshop pickup.';
marketplaceLetzshopLog.info('Export completed:', response);
} catch (error) {
marketplaceLetzshopLog.error('Failed to export:', error);
this.error = error.message || 'Failed to export products';
@@ -490,6 +593,24 @@ function adminMarketplaceLetzshop() {
}
},
/**
* Legacy download export method
*/
async downloadExport() {
return this.exportAllLanguages();
},
/**
* Format price for display
*/
formatPrice(price, currency = 'EUR') {
if (price == null) return '-';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency
}).format(price);
},
// ═══════════════════════════════════════════════════════════════
// ORDERS TAB
// ═══════════════════════════════════════════════════════════════