feat: redesign Letzshop products tab with product listing view

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

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

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

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 21:44:59 +01:00
parent 44c11181fd
commit d46b676e77
6 changed files with 692 additions and 258 deletions

View File

@@ -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
// ═══════════════════════════════════════════════════════════════