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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user