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)
|
@router.get("/stats", response_model=VendorProductStats)
|
||||||
def get_vendor_product_stats(
|
def get_vendor_product_stats(
|
||||||
|
vendor_id: int | None = Query(None, description="Filter stats by vendor ID"),
|
||||||
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 vendor product statistics for admin dashboard."""
|
"""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)
|
return VendorProductStats(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -363,3 +363,83 @@ def export_vendor_products_letzshop(
|
|||||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
"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
|
return result, total
|
||||||
|
|
||||||
def get_product_stats(self, db: Session) -> dict:
|
def get_product_stats(self, db: Session, vendor_id: int | None = None) -> dict:
|
||||||
"""Get vendor product statistics for admin dashboard."""
|
"""Get vendor product statistics for admin dashboard.
|
||||||
total = db.query(func.count(Product.id)).scalar() or 0
|
|
||||||
|
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 = (
|
active = (
|
||||||
db.query(func.count(Product.id))
|
db.query(func.count(Product.id))
|
||||||
|
.filter(base_filter)
|
||||||
.filter(Product.is_active == True) # noqa: E712
|
.filter(Product.is_active == True) # noqa: E712
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
@@ -89,6 +101,7 @@ class VendorProductService:
|
|||||||
|
|
||||||
featured = (
|
featured = (
|
||||||
db.query(func.count(Product.id))
|
db.query(func.count(Product.id))
|
||||||
|
.filter(base_filter)
|
||||||
.filter(Product.is_featured == True) # noqa: E712
|
.filter(Product.is_featured == True) # noqa: E712
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
@@ -97,6 +110,7 @@ class VendorProductService:
|
|||||||
# Digital/physical counts
|
# Digital/physical counts
|
||||||
digital = (
|
digital = (
|
||||||
db.query(func.count(Product.id))
|
db.query(func.count(Product.id))
|
||||||
|
.filter(base_filter)
|
||||||
.join(Product.marketplace_product)
|
.join(Product.marketplace_product)
|
||||||
.filter(Product.marketplace_product.has(is_digital=True))
|
.filter(Product.marketplace_product.has(is_digital=True))
|
||||||
.scalar()
|
.scalar()
|
||||||
@@ -104,17 +118,19 @@ class VendorProductService:
|
|||||||
)
|
)
|
||||||
physical = total - digital
|
physical = total - digital
|
||||||
|
|
||||||
# Count by vendor
|
# Count by vendor (only when not filtered by vendor_id)
|
||||||
vendor_counts = (
|
by_vendor = {}
|
||||||
db.query(
|
if not vendor_id:
|
||||||
Vendor.name,
|
vendor_counts = (
|
||||||
func.count(Product.id),
|
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)
|
by_vendor = {name or "unknown": count for name, count in vendor_counts}
|
||||||
.group_by(Vendor.name)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
by_vendor = {name or "unknown": count for name, count in vendor_counts}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -1,236 +1,382 @@
|
|||||||
{# app/templates/admin/partials/letzshop-products-tab.html #}
|
{# app/templates/admin/partials/letzshop-products-tab.html #}
|
||||||
{# Products tab for admin Letzshop management - Import & Export #}
|
{# Products tab for admin Letzshop management - Product listing with Import/Export #}
|
||||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
{% from 'shared/macros/pagination.html' import pagination %}
|
||||||
|
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<!-- Header with Import/Export Buttons -->
|
||||||
<!-- Import Section -->
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
<div>
|
||||||
<div class="p-6">
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Letzshop Products</h3>
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
<p class="text-sm text-gray-500 dark:text-gray-400">Vendor products synced with Letzshop marketplace</p>
|
||||||
Import Products from Letzshop
|
</div>
|
||||||
</h3>
|
<div class="flex items-center gap-3">
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
<!-- Import Button -->
|
||||||
Import products from a Letzshop CSV feed into the marketplace catalog.
|
<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>
|
</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 -->
|
<!-- CSV URL -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
x-model="importForm.csv_url"
|
x-model="importForm.csv_url"
|
||||||
type="url"
|
type="url"
|
||||||
required
|
|
||||||
placeholder="https://letzshop.lu/feeds/products.csv"
|
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>
|
</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 -->
|
<!-- Language Selection -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||||
Language
|
Language for this URL
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex gap-2">
|
||||||
<button
|
<button type="button" @click="importForm.language = 'fr'"
|
||||||
type="button"
|
: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'"
|
||||||
@click="importForm.language = 'fr'"
|
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||||
:class="importForm.language === 'fr'
|
<span class="fi fi-fr"></span> 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
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" @click="importForm.language = 'de'"
|
||||||
type="button"
|
: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'"
|
||||||
@click="importForm.language = 'de'"
|
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||||
:class="importForm.language === 'de'
|
<span class="fi fi-de"></span> 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>
|
</button>
|
||||||
<button
|
<button type="button" @click="importForm.language = 'en'"
|
||||||
type="button"
|
: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'"
|
||||||
@click="importForm.language = 'en'"
|
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||||
:class="importForm.language === 'en'
|
<span class="fi fi-gb"></span> 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batch Size -->
|
<div class="flex justify-end gap-3">
|
||||||
<div class="mb-6">
|
<button
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
type="button"
|
||||||
Batch Size
|
@click="showImportModal = false"
|
||||||
</label>
|
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"
|
||||||
{{ 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">
|
Cancel
|
||||||
Products processed per batch (100-5000)
|
</button>
|
||||||
</p>
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{# app/templates/admin/partials/letzshop-settings-tab.html #}
|
{# 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">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<!-- API Configuration Card -->
|
<!-- API Configuration Card -->
|
||||||
@@ -236,6 +237,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Carrier Settings Card -->
|
||||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
|
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ function adminMarketplaceLetzshop() {
|
|||||||
jobsFilter: { type: '', status: '' },
|
jobsFilter: { type: '', status: '' },
|
||||||
jobsPagination: { page: 1, per_page: 10, total: 0 },
|
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
|
// Modals
|
||||||
showTrackingModal: false,
|
showTrackingModal: false,
|
||||||
showOrderModal: false,
|
showOrderModal: false,
|
||||||
@@ -300,11 +310,12 @@ function adminMarketplaceLetzshop() {
|
|||||||
// Load Letzshop status and credentials
|
// Load Letzshop status and credentials
|
||||||
await this.loadLetzshopStatus();
|
await this.loadLetzshopStatus();
|
||||||
|
|
||||||
// Load orders, exceptions, and jobs
|
// Load orders, exceptions, products, and jobs
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadOrders(),
|
this.loadOrders(),
|
||||||
this.loadExceptions(),
|
this.loadExceptions(),
|
||||||
this.loadExceptionStats(),
|
this.loadExceptionStats(),
|
||||||
|
this.loadProducts(),
|
||||||
this.loadJobs()
|
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) {
|
async loadProducts() {
|
||||||
if (!this.selectedVendor) return;
|
if (!this.selectedVendor) {
|
||||||
|
this.products = [];
|
||||||
|
this.totalProducts = 0;
|
||||||
|
this.productStats = { total: 0, active: 0, inactive: 0, last_sync: null };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const urlMap = {
|
this.loadingProducts = true;
|
||||||
'fr': this.selectedVendor.letzshop_csv_url_fr,
|
|
||||||
'en': this.selectedVendor.letzshop_csv_url_en,
|
|
||||||
'de': this.selectedVendor.letzshop_csv_url_de
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = urlMap[language];
|
try {
|
||||||
if (url) {
|
const params = new URLSearchParams({
|
||||||
this.importForm.csv_url = url;
|
vendor_id: this.selectedVendor.id.toString(),
|
||||||
this.importForm.language = language;
|
skip: ((this.productsPage - 1) * this.productsLimit).toString(),
|
||||||
marketplaceLetzshopLog.info('Quick filled import form:', language, url);
|
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;
|
if (!this.selectedVendor || !this.importForm.csv_url) return;
|
||||||
|
|
||||||
this.importing = true;
|
this.importing = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.successMessage = '';
|
this.successMessage = '';
|
||||||
|
this.showImportModal = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
await apiClient.post('/admin/marketplace-import-jobs', {
|
||||||
vendor_id: this.selectedVendor.id,
|
vendor_id: this.selectedVendor.id,
|
||||||
source_url: this.importForm.csv_url,
|
source_url: this.importForm.csv_url,
|
||||||
marketplace: 'Letzshop',
|
marketplace: 'Letzshop',
|
||||||
language: this.importForm.language,
|
language: this.importForm.language,
|
||||||
batch_size: this.importForm.batch_size
|
batch_size: this.importForm.batch_size
|
||||||
};
|
});
|
||||||
|
|
||||||
await apiClient.post('/admin/marketplace-import-jobs', payload);
|
|
||||||
|
|
||||||
this.successMessage = 'Import job started successfully';
|
this.successMessage = 'Import job started successfully';
|
||||||
this.importForm.csv_url = '';
|
this.importForm.csv_url = '';
|
||||||
@@ -452,36 +557,34 @@ function adminMarketplaceLetzshop() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy method for backwards compatibility
|
||||||
|
*/
|
||||||
|
async startImport() {
|
||||||
|
return this.startImportFromUrl();
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// PRODUCTS TAB - EXPORT
|
// PRODUCTS TAB - EXPORT
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download product export CSV
|
* Export products for all languages to Letzshop pickup folder
|
||||||
*/
|
*/
|
||||||
async downloadExport() {
|
async exportAllLanguages() {
|
||||||
if (!this.selectedVendor) return;
|
if (!this.selectedVendor) return;
|
||||||
|
|
||||||
this.exporting = true;
|
this.exporting = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
|
this.successMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const response = await apiClient.post(`/admin/vendors/${this.selectedVendor.id}/export/letzshop`, {
|
||||||
language: this.exportLanguage,
|
include_inactive: this.exportIncludeInactive
|
||||||
include_inactive: this.exportIncludeInactive.toString()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `/api/v1/admin/vendors/${this.selectedVendor.id}/export/letzshop?${params}`;
|
this.successMessage = response.message || 'Export completed. CSV files are ready for Letzshop pickup.';
|
||||||
|
marketplaceLetzshopLog.info('Export completed:', response);
|
||||||
// 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';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
marketplaceLetzshopLog.error('Failed to export:', error);
|
marketplaceLetzshopLog.error('Failed to export:', error);
|
||||||
this.error = error.message || 'Failed to export products';
|
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
|
// ORDERS TAB
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user