refactor(js): migrate JavaScript files to module directories
Move 47 JS files from static/{admin,vendor,shared}/js/ to their
respective module directories app/modules/*/static/*/js/:
- Orders: orders.js, order-detail.js
- Catalog: products.js (renamed from vendor-products.js), product-*.js
- Inventory: inventory.js (admin & vendor)
- Customers: customers.js, users.js, user-*.js
- Billing: billing-history.js, subscriptions.js, subscription-tiers.js,
billing.js, invoices.js, feature-store.js, upgrade-prompts.js
- Messaging: messages.js, notifications.js, email-templates.js
- Marketplace: marketplace*.js, letzshop*.js, onboarding.js
- Monitoring: monitoring.js, background-tasks.js, imports.js, logs.js
- Dev Tools: testing-*.js, code-quality-*.js
Update 39 templates to reference new module static paths using
url_for('{module}_static', path='...') pattern.
Files staying in static/ (platform core):
- admin: dashboard, login, platforms, vendors, companies, admin-users,
settings, components, init-alpine, module-config
- vendor: dashboard, login, profile, settings, team, media, init-alpine
- shared: api-client, utils, money, icons, log-config, vendor-selector,
media-picker
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
403
app/modules/catalog/static/admin/js/product-create.js
Normal file
403
app/modules/catalog/static/admin/js/product-create.js
Normal file
@@ -0,0 +1,403 @@
|
||||
// app/modules/catalog/static/admin/js/product-create.js
|
||||
/**
|
||||
* Admin vendor product create page logic
|
||||
* Create new vendor product entries with translations
|
||||
*/
|
||||
|
||||
const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate ||
|
||||
window.LogConfig.createLogger('adminVendorProductCreate', false);
|
||||
|
||||
adminVendorProductCreateLog.info('Loading...');
|
||||
|
||||
function adminVendorProductCreate() {
|
||||
adminVendorProductCreateLog.info('adminVendorProductCreate() called');
|
||||
|
||||
// Default translations structure
|
||||
const defaultTranslations = () => ({
|
||||
en: { title: '', description: '' },
|
||||
fr: { title: '', description: '' },
|
||||
de: { title: '', description: '' },
|
||||
lu: { title: '', description: '' }
|
||||
});
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Include media picker functionality (vendor ID getter will be bound via loadMediaLibrary override)
|
||||
...mediaPickerMixin(() => null, false),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'vendor-products',
|
||||
|
||||
// Loading states
|
||||
loading: false,
|
||||
saving: false,
|
||||
|
||||
// Tom Select instance
|
||||
vendorSelectInstance: null,
|
||||
|
||||
// Active language tab
|
||||
activeLanguage: 'en',
|
||||
|
||||
// Form data
|
||||
form: {
|
||||
vendor_id: null,
|
||||
// Translations by language
|
||||
translations: defaultTranslations(),
|
||||
// Product identifiers
|
||||
vendor_sku: '',
|
||||
brand: '',
|
||||
gtin: '',
|
||||
gtin_type: '',
|
||||
// Pricing
|
||||
price: null,
|
||||
sale_price: null,
|
||||
currency: 'EUR',
|
||||
tax_rate_percent: 17,
|
||||
availability: '',
|
||||
// Images
|
||||
primary_image_url: '',
|
||||
additional_images: [],
|
||||
// Status
|
||||
is_active: true,
|
||||
is_featured: false,
|
||||
is_digital: false
|
||||
},
|
||||
|
||||
async init() {
|
||||
adminVendorProductCreateLog.info('Vendor Product Create init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._adminVendorProductCreateInitialized) {
|
||||
adminVendorProductCreateLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._adminVendorProductCreateInitialized = true;
|
||||
|
||||
// Initialize Tom Select
|
||||
this.initVendorSelect();
|
||||
|
||||
adminVendorProductCreateLog.info('Vendor Product Create initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize Tom Select for vendor autocomplete
|
||||
*/
|
||||
initVendorSelect() {
|
||||
const selectEl = this.$refs.vendorSelect;
|
||||
if (!selectEl) {
|
||||
adminVendorProductCreateLog.warn('Vendor select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for Tom Select to be available
|
||||
if (typeof TomSelect === 'undefined') {
|
||||
adminVendorProductCreateLog.warn('TomSelect not loaded, retrying in 100ms');
|
||||
setTimeout(() => this.initVendorSelect(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.vendorSelectInstance = new TomSelect(selectEl, {
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
searchField: ['name', 'vendor_code'],
|
||||
placeholder: 'Search vendor...',
|
||||
load: async (query, callback) => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors', {
|
||||
search: query,
|
||||
limit: 50
|
||||
});
|
||||
callback(response.vendors || []);
|
||||
} catch (error) {
|
||||
adminVendorProductCreateLog.error('Failed to search vendors:', error);
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
render: {
|
||||
option: (data, escape) => {
|
||||
return `<div class="flex items-center justify-between py-1">
|
||||
<span>${escape(data.name)}</span>
|
||||
<span class="text-xs text-gray-400 font-mono">${escape(data.vendor_code || '')}</span>
|
||||
</div>`;
|
||||
},
|
||||
item: (data, escape) => {
|
||||
return `<div>${escape(data.name)}</div>`;
|
||||
}
|
||||
},
|
||||
onChange: (value) => {
|
||||
this.form.vendor_id = value ? parseInt(value) : null;
|
||||
}
|
||||
});
|
||||
|
||||
adminVendorProductCreateLog.info('Vendor select initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a unique vendor SKU
|
||||
* Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness)
|
||||
*/
|
||||
generateSku() {
|
||||
const vendorId = this.form.vendor_id || 0;
|
||||
|
||||
// Generate random alphanumeric segments
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const generateSegment = (length) => {
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// First segment includes vendor ID (padded)
|
||||
const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4);
|
||||
|
||||
// Generate SKU: VID + random + random
|
||||
const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`;
|
||||
this.form.vendor_sku = sku;
|
||||
|
||||
adminVendorProductCreateLog.info('Generated SKU:', sku);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the product
|
||||
*/
|
||||
async createProduct() {
|
||||
if (!this.form.vendor_id) {
|
||||
Utils.showToast('Please select a vendor', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.form.translations.en.title?.trim()) {
|
||||
Utils.showToast('Please enter a product title (English)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
// Build translations object for API (only include non-empty)
|
||||
const translations = {};
|
||||
for (const lang of ['en', 'fr', 'de', 'lu']) {
|
||||
const t = this.form.translations[lang];
|
||||
if (t.title?.trim() || t.description?.trim()) {
|
||||
translations[lang] = {
|
||||
title: t.title?.trim() || null,
|
||||
description: t.description?.trim() || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build create payload
|
||||
const payload = {
|
||||
vendor_id: this.form.vendor_id,
|
||||
translations: Object.keys(translations).length > 0 ? translations : null,
|
||||
// Product identifiers
|
||||
brand: this.form.brand?.trim() || null,
|
||||
vendor_sku: this.form.vendor_sku?.trim() || null,
|
||||
gtin: this.form.gtin?.trim() || null,
|
||||
gtin_type: this.form.gtin_type || null,
|
||||
// Pricing
|
||||
price: this.form.price !== null && this.form.price !== ''
|
||||
? parseFloat(this.form.price) : null,
|
||||
sale_price: this.form.sale_price !== null && this.form.sale_price !== ''
|
||||
? parseFloat(this.form.sale_price) : null,
|
||||
currency: this.form.currency || 'EUR',
|
||||
tax_rate_percent: this.form.tax_rate_percent !== null
|
||||
? parseInt(this.form.tax_rate_percent) : 17,
|
||||
availability: this.form.availability || null,
|
||||
// Images
|
||||
primary_image_url: this.form.primary_image_url?.trim() || null,
|
||||
additional_images: this.form.additional_images?.length > 0
|
||||
? this.form.additional_images : null,
|
||||
// Status
|
||||
is_active: this.form.is_active,
|
||||
is_featured: this.form.is_featured,
|
||||
is_digital: this.form.is_digital
|
||||
};
|
||||
|
||||
adminVendorProductCreateLog.info('Creating product with payload:', payload);
|
||||
|
||||
const response = await apiClient.post('/admin/vendor-products', payload);
|
||||
|
||||
adminVendorProductCreateLog.info('Product created:', response.id);
|
||||
|
||||
Utils.showToast('Product created successfully', 'success');
|
||||
|
||||
// Redirect to the new product's detail page
|
||||
setTimeout(() => {
|
||||
window.location.href = `/admin/vendor-products/${response.id}`;
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
adminVendorProductCreateLog.error('Failed to create product:', error);
|
||||
Utils.showToast(error.message || 'Failed to create product', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// === Media Picker Overrides ===
|
||||
// These override the mixin methods to use proper form context
|
||||
|
||||
/**
|
||||
* Load media library for the selected vendor
|
||||
*/
|
||||
async loadMediaLibrary() {
|
||||
const vendorId = this.form?.vendor_id;
|
||||
|
||||
if (!vendorId) {
|
||||
adminVendorProductCreateLog.warn('Media picker: No vendor ID selected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaPickerState.loading = true;
|
||||
this.mediaPickerState.skip = 0;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: '0',
|
||||
limit: this.mediaPickerState.limit.toString(),
|
||||
media_type: 'image',
|
||||
});
|
||||
|
||||
if (this.mediaPickerState.search) {
|
||||
params.append('search', this.mediaPickerState.search);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/media/vendors/${vendorId}?${params.toString()}`
|
||||
);
|
||||
|
||||
this.mediaPickerState.media = response.media || [];
|
||||
this.mediaPickerState.total = response.total || 0;
|
||||
} catch (error) {
|
||||
adminVendorProductCreateLog.error('Failed to load media library:', error);
|
||||
Utils.showToast('Failed to load media library', 'error');
|
||||
} finally {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load more media (pagination)
|
||||
*/
|
||||
async loadMoreMedia() {
|
||||
const vendorId = this.form?.vendor_id;
|
||||
if (!vendorId) return;
|
||||
|
||||
this.mediaPickerState.loading = true;
|
||||
this.mediaPickerState.skip += this.mediaPickerState.limit;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: this.mediaPickerState.skip.toString(),
|
||||
limit: this.mediaPickerState.limit.toString(),
|
||||
media_type: 'image',
|
||||
});
|
||||
|
||||
if (this.mediaPickerState.search) {
|
||||
params.append('search', this.mediaPickerState.search);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/media/vendors/${vendorId}?${params.toString()}`
|
||||
);
|
||||
|
||||
this.mediaPickerState.media = [
|
||||
...this.mediaPickerState.media,
|
||||
...(response.media || [])
|
||||
];
|
||||
} catch (error) {
|
||||
adminVendorProductCreateLog.error('Failed to load more media:', error);
|
||||
} finally {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a new media file
|
||||
*/
|
||||
async uploadMediaFile(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const vendorId = this.form?.vendor_id;
|
||||
|
||||
if (!vendorId) {
|
||||
Utils.showToast('Please select a vendor first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
Utils.showToast('Please select an image file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
Utils.showToast('Image must be less than 10MB', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaPickerState.uploading = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.postFormData(
|
||||
`/admin/media/vendors/${vendorId}/upload?folder=products`,
|
||||
formData
|
||||
);
|
||||
|
||||
if (response.success && response.media) {
|
||||
this.mediaPickerState.media.unshift(response.media);
|
||||
this.mediaPickerState.total++;
|
||||
this.toggleMediaSelection(response.media);
|
||||
Utils.showToast('Image uploaded successfully', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
adminVendorProductCreateLog.error('Failed to upload image:', error);
|
||||
Utils.showToast(error.message || 'Failed to upload image', 'error');
|
||||
} finally {
|
||||
this.mediaPickerState.uploading = false;
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set main image from media picker
|
||||
*/
|
||||
setMainImage(media) {
|
||||
this.form.primary_image_url = media.url;
|
||||
adminVendorProductCreateLog.info('Main image set:', media.url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add additional images from media picker
|
||||
*/
|
||||
addAdditionalImages(mediaList) {
|
||||
const newUrls = mediaList.map(m => m.url);
|
||||
this.form.additional_images = [
|
||||
...this.form.additional_images,
|
||||
...newUrls
|
||||
];
|
||||
adminVendorProductCreateLog.info('Additional images added:', newUrls);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an additional image by index
|
||||
*/
|
||||
removeAdditionalImage(index) {
|
||||
this.form.additional_images.splice(index, 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the main image
|
||||
*/
|
||||
clearMainImage() {
|
||||
this.form.primary_image_url = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
170
app/modules/catalog/static/admin/js/product-detail.js
Normal file
170
app/modules/catalog/static/admin/js/product-detail.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// app/modules/catalog/static/admin/js/product-detail.js
|
||||
/**
|
||||
* Admin vendor product detail page logic
|
||||
* View and manage individual vendor catalog products
|
||||
*/
|
||||
|
||||
const adminVendorProductDetailLog = window.LogConfig.loggers.adminVendorProductDetail ||
|
||||
window.LogConfig.createLogger('adminVendorProductDetail', false);
|
||||
|
||||
adminVendorProductDetailLog.info('Loading...');
|
||||
|
||||
function adminVendorProductDetail() {
|
||||
adminVendorProductDetailLog.info('adminVendorProductDetail() called');
|
||||
|
||||
// Extract product ID from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const productId = parseInt(pathParts[pathParts.length - 1]);
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'vendor-products',
|
||||
|
||||
// Product ID from URL
|
||||
productId: productId,
|
||||
|
||||
// Loading states
|
||||
loading: true,
|
||||
error: '',
|
||||
|
||||
// Product data
|
||||
product: null,
|
||||
|
||||
// Modals
|
||||
showRemoveModal: false,
|
||||
removing: false,
|
||||
|
||||
async init() {
|
||||
adminVendorProductDetailLog.info('Vendor Product Detail init() called, ID:', this.productId);
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._adminVendorProductDetailInitialized) {
|
||||
adminVendorProductDetailLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._adminVendorProductDetailInitialized = true;
|
||||
|
||||
// Load product data
|
||||
await this.loadProduct();
|
||||
|
||||
adminVendorProductDetailLog.info('Vendor Product Detail initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load product details
|
||||
*/
|
||||
async loadProduct() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/vendor-products/${this.productId}`);
|
||||
this.product = response;
|
||||
adminVendorProductDetailLog.info('Loaded product:', this.product.id);
|
||||
} catch (error) {
|
||||
adminVendorProductDetailLog.error('Failed to load product:', error);
|
||||
this.error = error.message || 'Failed to load product details';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Open edit modal (placeholder for future implementation)
|
||||
*/
|
||||
openEditModal() {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: 'Edit functionality coming soon', type: 'info' }
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
async toggleActive() {
|
||||
// TODO: Implement PATCH endpoint for status update
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: {
|
||||
message: 'Status toggle functionality coming soon',
|
||||
type: 'info'
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm remove
|
||||
*/
|
||||
confirmRemove() {
|
||||
this.showRemoveModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute remove
|
||||
*/
|
||||
async executeRemove() {
|
||||
this.removing = true;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/vendor-products/${this.productId}`);
|
||||
|
||||
adminVendorProductDetailLog.info('Product removed:', this.productId);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: {
|
||||
message: 'Product removed from catalog successfully',
|
||||
type: 'success'
|
||||
}
|
||||
}));
|
||||
|
||||
// Redirect to vendor products list
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/vendor-products';
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
adminVendorProductDetailLog.error('Failed to remove product:', error);
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: error.message || 'Failed to remove product', type: 'error' }
|
||||
}));
|
||||
} finally {
|
||||
this.removing = false;
|
||||
this.showRemoveModal = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format price for display
|
||||
*/
|
||||
formatPrice(price, currency = 'EUR') {
|
||||
if (price === null || price === undefined) return '-';
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
if (isNaN(numPrice)) return price;
|
||||
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR'
|
||||
}).format(numPrice);
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
445
app/modules/catalog/static/admin/js/product-edit.js
Normal file
445
app/modules/catalog/static/admin/js/product-edit.js
Normal file
@@ -0,0 +1,445 @@
|
||||
// app/modules/catalog/static/admin/js/product-edit.js
|
||||
/**
|
||||
* Admin vendor product edit page logic
|
||||
* Edit vendor product information with translations
|
||||
*/
|
||||
|
||||
const adminVendorProductEditLog = window.LogConfig.loggers.adminVendorProductEdit ||
|
||||
window.LogConfig.createLogger('adminVendorProductEdit', false);
|
||||
|
||||
adminVendorProductEditLog.info('Loading...');
|
||||
|
||||
function adminVendorProductEdit() {
|
||||
adminVendorProductEditLog.info('adminVendorProductEdit() called');
|
||||
|
||||
// Extract product ID from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const productId = parseInt(pathParts[pathParts.length - 2]); // /vendor-products/{id}/edit
|
||||
|
||||
// Default translations structure
|
||||
const defaultTranslations = () => ({
|
||||
en: { title: '', description: '' },
|
||||
fr: { title: '', description: '' },
|
||||
de: { title: '', description: '' },
|
||||
lu: { title: '', description: '' }
|
||||
});
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Include media picker functionality (vendor ID comes from loaded product)
|
||||
...mediaPickerMixin(() => null, false),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'vendor-products',
|
||||
|
||||
// Product ID from URL
|
||||
productId: productId,
|
||||
|
||||
// Loading states
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: '',
|
||||
|
||||
// Product data from API
|
||||
product: null,
|
||||
|
||||
// Active language tab
|
||||
activeLanguage: 'en',
|
||||
|
||||
// Form data
|
||||
form: {
|
||||
// Translations by language
|
||||
translations: defaultTranslations(),
|
||||
// Product identifiers
|
||||
vendor_sku: '',
|
||||
brand: '',
|
||||
gtin: '',
|
||||
gtin_type: 'ean13',
|
||||
// Pricing
|
||||
price: null,
|
||||
sale_price: null,
|
||||
currency: 'EUR',
|
||||
tax_rate_percent: 17,
|
||||
availability: '',
|
||||
// Images
|
||||
primary_image_url: '',
|
||||
additional_images: [],
|
||||
// Product type & status
|
||||
is_digital: false,
|
||||
is_active: true,
|
||||
is_featured: false,
|
||||
// Optional supplier info
|
||||
supplier: '',
|
||||
cost: null
|
||||
},
|
||||
|
||||
async init() {
|
||||
adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId);
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._adminVendorProductEditInitialized) {
|
||||
adminVendorProductEditLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._adminVendorProductEditInitialized = true;
|
||||
|
||||
// Load product data
|
||||
await this.loadProduct();
|
||||
|
||||
adminVendorProductEditLog.info('Vendor Product Edit initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load product details and populate form
|
||||
*/
|
||||
async loadProduct() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/vendor-products/${this.productId}`);
|
||||
this.product = response;
|
||||
|
||||
adminVendorProductEditLog.info('Loaded product:', response);
|
||||
|
||||
// Populate translations from vendor_translations
|
||||
const translations = defaultTranslations();
|
||||
if (response.vendor_translations) {
|
||||
for (const lang of ['en', 'fr', 'de', 'lu']) {
|
||||
if (response.vendor_translations[lang]) {
|
||||
translations[lang] = {
|
||||
title: response.vendor_translations[lang].title || '',
|
||||
description: response.vendor_translations[lang].description || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form with current values
|
||||
this.form = {
|
||||
translations: translations,
|
||||
// Product identifiers
|
||||
vendor_sku: response.vendor_sku || '',
|
||||
brand: response.brand || '',
|
||||
gtin: response.gtin || '',
|
||||
gtin_type: response.gtin_type || 'ean13',
|
||||
// Pricing (convert cents to euros if stored as cents)
|
||||
price: response.price || null,
|
||||
sale_price: response.sale_price || null,
|
||||
currency: response.currency || 'EUR',
|
||||
tax_rate_percent: response.tax_rate_percent ?? 17,
|
||||
availability: response.availability || '',
|
||||
// Images
|
||||
primary_image_url: response.primary_image_url || '',
|
||||
additional_images: response.additional_images || [],
|
||||
// Product type & status
|
||||
is_digital: response.is_digital ?? false,
|
||||
is_active: response.is_active ?? true,
|
||||
is_featured: response.is_featured ?? false,
|
||||
// Optional supplier info
|
||||
supplier: response.supplier || '',
|
||||
cost: response.cost || null
|
||||
};
|
||||
|
||||
adminVendorProductEditLog.info('Form initialized:', this.form);
|
||||
} catch (error) {
|
||||
adminVendorProductEditLog.error('Failed to load product:', error);
|
||||
this.error = error.message || 'Failed to load product details';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if form is valid (all mandatory fields filled)
|
||||
*/
|
||||
isFormValid() {
|
||||
// English title and description are required
|
||||
if (!this.form.translations.en.title?.trim()) return false;
|
||||
if (!this.form.translations.en.description?.trim()) return false;
|
||||
|
||||
// Product identifiers
|
||||
if (!this.form.vendor_sku?.trim()) return false;
|
||||
if (!this.form.brand?.trim()) return false;
|
||||
if (!this.form.gtin?.trim()) return false;
|
||||
if (!this.form.gtin_type) return false;
|
||||
|
||||
// Pricing
|
||||
if (this.form.price === null || this.form.price === '' || this.form.price < 0) return false;
|
||||
if (!this.form.currency) return false;
|
||||
if (this.form.tax_rate_percent === null || this.form.tax_rate_percent === '') return false;
|
||||
|
||||
// Image
|
||||
if (!this.form.primary_image_url?.trim()) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a unique vendor SKU
|
||||
* Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness)
|
||||
*/
|
||||
generateSku() {
|
||||
const vendorId = this.product?.vendor_id || 0;
|
||||
|
||||
// Generate random alphanumeric segments
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const generateSegment = (length) => {
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// First segment includes vendor ID (padded)
|
||||
const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4);
|
||||
|
||||
// Generate SKU: VID + random + random
|
||||
const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`;
|
||||
this.form.vendor_sku = sku;
|
||||
|
||||
adminVendorProductEditLog.info('Generated SKU:', sku);
|
||||
},
|
||||
|
||||
/**
|
||||
* Save product changes
|
||||
*/
|
||||
async saveProduct() {
|
||||
if (!this.isFormValid()) {
|
||||
Utils.showToast('Please fill in all required fields', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
// Build translations object for API
|
||||
const translations = {};
|
||||
for (const lang of ['en', 'fr', 'de', 'lu']) {
|
||||
const t = this.form.translations[lang];
|
||||
// Only include if there's actual content
|
||||
if (t.title?.trim() || t.description?.trim()) {
|
||||
translations[lang] = {
|
||||
title: t.title?.trim() || null,
|
||||
description: t.description?.trim() || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build update payload
|
||||
const payload = {
|
||||
translations: Object.keys(translations).length > 0 ? translations : null,
|
||||
// Product identifiers
|
||||
vendor_sku: this.form.vendor_sku?.trim() || null,
|
||||
brand: this.form.brand?.trim() || null,
|
||||
gtin: this.form.gtin?.trim() || null,
|
||||
gtin_type: this.form.gtin_type || null,
|
||||
// Pricing
|
||||
price: this.form.price !== null && this.form.price !== ''
|
||||
? parseFloat(this.form.price) : null,
|
||||
sale_price: this.form.sale_price !== null && this.form.sale_price !== ''
|
||||
? parseFloat(this.form.sale_price) : null,
|
||||
currency: this.form.currency || null,
|
||||
tax_rate_percent: this.form.tax_rate_percent !== null && this.form.tax_rate_percent !== ''
|
||||
? parseInt(this.form.tax_rate_percent) : null,
|
||||
availability: this.form.availability || null,
|
||||
// Images
|
||||
primary_image_url: this.form.primary_image_url?.trim() || null,
|
||||
additional_images: this.form.additional_images?.length > 0
|
||||
? this.form.additional_images : null,
|
||||
// Status
|
||||
is_digital: this.form.is_digital,
|
||||
is_active: this.form.is_active,
|
||||
is_featured: this.form.is_featured,
|
||||
// Optional supplier info
|
||||
supplier: this.form.supplier?.trim() || null,
|
||||
cost: this.form.cost !== null && this.form.cost !== ''
|
||||
? parseFloat(this.form.cost) : null
|
||||
};
|
||||
|
||||
adminVendorProductEditLog.info('Saving payload:', payload);
|
||||
|
||||
await apiClient.patch(`/admin/vendor-products/${this.productId}`, payload);
|
||||
|
||||
adminVendorProductEditLog.info('Product saved:', this.productId);
|
||||
|
||||
Utils.showToast('Product updated successfully', 'success');
|
||||
|
||||
// Redirect to detail page
|
||||
setTimeout(() => {
|
||||
window.location.href = `/admin/vendor-products/${this.productId}`;
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
adminVendorProductEditLog.error('Failed to save product:', error);
|
||||
Utils.showToast(error.message || 'Failed to save product', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// === Media Picker Overrides ===
|
||||
// These override the mixin methods to use proper form/product context
|
||||
|
||||
/**
|
||||
* Load media library for the product's vendor
|
||||
*/
|
||||
async loadMediaLibrary() {
|
||||
const vendorId = this.product?.vendor_id;
|
||||
|
||||
if (!vendorId) {
|
||||
adminVendorProductEditLog.warn('Media picker: No vendor ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaPickerState.loading = true;
|
||||
this.mediaPickerState.skip = 0;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: '0',
|
||||
limit: this.mediaPickerState.limit.toString(),
|
||||
media_type: 'image',
|
||||
});
|
||||
|
||||
if (this.mediaPickerState.search) {
|
||||
params.append('search', this.mediaPickerState.search);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/media/vendors/${vendorId}?${params.toString()}`
|
||||
);
|
||||
|
||||
this.mediaPickerState.media = response.media || [];
|
||||
this.mediaPickerState.total = response.total || 0;
|
||||
} catch (error) {
|
||||
adminVendorProductEditLog.error('Failed to load media library:', error);
|
||||
Utils.showToast('Failed to load media library', 'error');
|
||||
} finally {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load more media (pagination)
|
||||
*/
|
||||
async loadMoreMedia() {
|
||||
const vendorId = this.product?.vendor_id;
|
||||
if (!vendorId) return;
|
||||
|
||||
this.mediaPickerState.loading = true;
|
||||
this.mediaPickerState.skip += this.mediaPickerState.limit;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: this.mediaPickerState.skip.toString(),
|
||||
limit: this.mediaPickerState.limit.toString(),
|
||||
media_type: 'image',
|
||||
});
|
||||
|
||||
if (this.mediaPickerState.search) {
|
||||
params.append('search', this.mediaPickerState.search);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/media/vendors/${vendorId}?${params.toString()}`
|
||||
);
|
||||
|
||||
this.mediaPickerState.media = [
|
||||
...this.mediaPickerState.media,
|
||||
...(response.media || [])
|
||||
];
|
||||
} catch (error) {
|
||||
adminVendorProductEditLog.error('Failed to load more media:', error);
|
||||
} finally {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a new media file
|
||||
*/
|
||||
async uploadMediaFile(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const vendorId = this.product?.vendor_id;
|
||||
|
||||
if (!vendorId) {
|
||||
Utils.showToast('No vendor associated with this product', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
Utils.showToast('Please select an image file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
Utils.showToast('Image must be less than 10MB', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaPickerState.uploading = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.postFormData(
|
||||
`/admin/media/vendors/${vendorId}/upload?folder=products`,
|
||||
formData
|
||||
);
|
||||
|
||||
if (response.success && response.media) {
|
||||
this.mediaPickerState.media.unshift(response.media);
|
||||
this.mediaPickerState.total++;
|
||||
this.toggleMediaSelection(response.media);
|
||||
Utils.showToast('Image uploaded successfully', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
adminVendorProductEditLog.error('Failed to upload image:', error);
|
||||
Utils.showToast(error.message || 'Failed to upload image', 'error');
|
||||
} finally {
|
||||
this.mediaPickerState.uploading = false;
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set main image from media picker
|
||||
*/
|
||||
setMainImage(media) {
|
||||
this.form.primary_image_url = media.url;
|
||||
adminVendorProductEditLog.info('Main image set:', media.url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add additional images from media picker
|
||||
*/
|
||||
addAdditionalImages(mediaList) {
|
||||
const newUrls = mediaList.map(m => m.url);
|
||||
this.form.additional_images = [
|
||||
...this.form.additional_images,
|
||||
...newUrls
|
||||
];
|
||||
adminVendorProductEditLog.info('Additional images added:', newUrls);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an additional image by index
|
||||
*/
|
||||
removeAdditionalImage(index) {
|
||||
this.form.additional_images.splice(index, 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the main image
|
||||
*/
|
||||
clearMainImage() {
|
||||
this.form.primary_image_url = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
441
app/modules/catalog/static/admin/js/products.js
Normal file
441
app/modules/catalog/static/admin/js/products.js
Normal file
@@ -0,0 +1,441 @@
|
||||
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
||||
// static/admin/js/vendor-products.js
|
||||
/**
|
||||
* Admin vendor products page logic
|
||||
* Browse vendor-specific product catalogs with override capability
|
||||
*/
|
||||
|
||||
const adminVendorProductsLog = window.LogConfig.loggers.adminVendorProducts ||
|
||||
window.LogConfig.createLogger('adminVendorProducts', false);
|
||||
|
||||
adminVendorProductsLog.info('Loading...');
|
||||
|
||||
function adminVendorProducts() {
|
||||
adminVendorProductsLog.info('adminVendorProducts() called');
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'vendor-products',
|
||||
|
||||
// Loading states
|
||||
loading: true,
|
||||
error: '',
|
||||
|
||||
// Products data
|
||||
products: [],
|
||||
stats: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
featured: 0,
|
||||
digital: 0,
|
||||
physical: 0,
|
||||
by_vendor: {}
|
||||
},
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
search: '',
|
||||
vendor_id: '',
|
||||
is_active: '',
|
||||
is_featured: ''
|
||||
},
|
||||
|
||||
// Selected vendor (for prominent display and filtering)
|
||||
selectedVendor: null,
|
||||
|
||||
// Tom Select instance
|
||||
vendorSelectInstance: null,
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
|
||||
// Product detail modal state
|
||||
showProductModal: false,
|
||||
selectedProduct: null,
|
||||
|
||||
// Remove confirmation modal state
|
||||
showRemoveModal: false,
|
||||
productToRemove: null,
|
||||
removing: false,
|
||||
|
||||
// Debounce timer
|
||||
searchTimeout: null,
|
||||
|
||||
// Computed: Total pages
|
||||
get totalPages() {
|
||||
return this.pagination.pages;
|
||||
},
|
||||
|
||||
// Computed: Start index for pagination display
|
||||
get startIndex() {
|
||||
if (this.pagination.total === 0) return 0;
|
||||
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
||||
},
|
||||
|
||||
// Computed: End index for pagination display
|
||||
get endIndex() {
|
||||
const end = this.pagination.page * this.pagination.per_page;
|
||||
return end > this.pagination.total ? this.pagination.total : end;
|
||||
},
|
||||
|
||||
// Computed: Page numbers for pagination
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
const totalPages = this.totalPages;
|
||||
const current = this.pagination.page;
|
||||
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (current > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(totalPages - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (current < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
|
||||
async init() {
|
||||
adminVendorProductsLog.info('Vendor Products init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._adminVendorProductsInitialized) {
|
||||
adminVendorProductsLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._adminVendorProductsInitialized = true;
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
// Initialize Tom Select for vendor filter
|
||||
this.initVendorSelect();
|
||||
|
||||
// Check localStorage for saved vendor
|
||||
const savedVendorId = localStorage.getItem('vendor_products_selected_vendor_id');
|
||||
if (savedVendorId) {
|
||||
adminVendorProductsLog.info('Restoring saved vendor:', savedVendorId);
|
||||
// Restore vendor after a short delay to ensure TomSelect is ready
|
||||
setTimeout(async () => {
|
||||
await this.restoreSavedVendor(parseInt(savedVendorId));
|
||||
}, 200);
|
||||
// Load stats but not products (restoreSavedVendor will do that)
|
||||
await this.loadStats();
|
||||
} else {
|
||||
// No saved vendor - load all data including unfiltered products
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadProducts()
|
||||
]);
|
||||
}
|
||||
|
||||
adminVendorProductsLog.info('Vendor Products initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore saved vendor from localStorage
|
||||
*/
|
||||
async restoreSavedVendor(vendorId) {
|
||||
try {
|
||||
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
|
||||
if (this.vendorSelectInstance && vendor) {
|
||||
// Add the vendor as an option and select it
|
||||
this.vendorSelectInstance.addOption({
|
||||
id: vendor.id,
|
||||
name: vendor.name,
|
||||
vendor_code: vendor.vendor_code
|
||||
});
|
||||
this.vendorSelectInstance.setValue(vendor.id, true);
|
||||
|
||||
// Set the filter state
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = vendor.id;
|
||||
|
||||
adminVendorProductsLog.info('Restored vendor:', vendor.name);
|
||||
|
||||
// Load products with the vendor filter applied
|
||||
await this.loadProducts();
|
||||
}
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
|
||||
localStorage.removeItem('vendor_products_selected_vendor_id');
|
||||
// Load unfiltered products as fallback
|
||||
await this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize Tom Select for vendor autocomplete
|
||||
*/
|
||||
initVendorSelect() {
|
||||
const selectEl = this.$refs.vendorSelect;
|
||||
if (!selectEl) {
|
||||
adminVendorProductsLog.warn('Vendor select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for Tom Select to be available
|
||||
if (typeof TomSelect === 'undefined') {
|
||||
adminVendorProductsLog.warn('TomSelect not loaded, retrying in 100ms');
|
||||
setTimeout(() => this.initVendorSelect(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.vendorSelectInstance = new TomSelect(selectEl, {
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
searchField: ['name', 'vendor_code'],
|
||||
placeholder: 'Filter by vendor...',
|
||||
allowEmptyOption: true,
|
||||
load: async (query, callback) => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/vendors', {
|
||||
search: query,
|
||||
limit: 50
|
||||
});
|
||||
callback(response.vendors || []);
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.error('Failed to search vendors:', error);
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
render: {
|
||||
option: (data, escape) => {
|
||||
return `<div class="flex items-center justify-between py-1">
|
||||
<span>${escape(data.name)}</span>
|
||||
<span class="text-xs text-gray-400 font-mono">${escape(data.vendor_code || '')}</span>
|
||||
</div>`;
|
||||
},
|
||||
item: (data, escape) => {
|
||||
return `<div>${escape(data.name)}</div>`;
|
||||
}
|
||||
},
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
const vendor = this.vendorSelectInstance.options[value];
|
||||
this.selectedVendor = vendor;
|
||||
this.filters.vendor_id = value;
|
||||
// Save to localStorage
|
||||
localStorage.setItem('vendor_products_selected_vendor_id', value.toString());
|
||||
} else {
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('vendor_products_selected_vendor_id');
|
||||
}
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
this.loadStats();
|
||||
}
|
||||
});
|
||||
|
||||
adminVendorProductsLog.info('Vendor select initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear vendor filter
|
||||
*/
|
||||
clearVendorFilter() {
|
||||
if (this.vendorSelectInstance) {
|
||||
this.vendorSelectInstance.clear();
|
||||
}
|
||||
this.selectedVendor = null;
|
||||
this.filters.vendor_id = '';
|
||||
// Clear from localStorage
|
||||
localStorage.removeItem('vendor_products_selected_vendor_id');
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load product statistics
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.vendor_id) {
|
||||
params.append('vendor_id', this.filters.vendor_id);
|
||||
}
|
||||
const url = params.toString() ? `/admin/vendor-products/stats?${params}` : '/admin/vendor-products/stats';
|
||||
const response = await apiClient.get(url);
|
||||
this.stats = response;
|
||||
adminVendorProductsLog.info('Loaded stats:', this.stats);
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.error('Failed to load stats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load products with filtering and pagination
|
||||
*/
|
||||
async loadProducts() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.pagination.page - 1) * this.pagination.per_page,
|
||||
limit: this.pagination.per_page
|
||||
});
|
||||
|
||||
// Add filters
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
if (this.filters.vendor_id) {
|
||||
params.append('vendor_id', this.filters.vendor_id);
|
||||
}
|
||||
if (this.filters.is_active !== '') {
|
||||
params.append('is_active', this.filters.is_active);
|
||||
}
|
||||
if (this.filters.is_featured !== '') {
|
||||
params.append('is_featured', this.filters.is_featured);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/admin/vendor-products?${params.toString()}`);
|
||||
|
||||
this.products = response.products || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
|
||||
|
||||
adminVendorProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total);
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.error('Failed to load products:', error);
|
||||
this.error = error.message || 'Failed to load products';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Debounced search handler
|
||||
*/
|
||||
debouncedSearch() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh products list
|
||||
*/
|
||||
async refresh() {
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadVendors(),
|
||||
this.loadProducts()
|
||||
]);
|
||||
},
|
||||
|
||||
/**
|
||||
* View product details - navigate to detail page
|
||||
*/
|
||||
viewProduct(productId) {
|
||||
adminVendorProductsLog.info('Navigating to product detail:', productId);
|
||||
window.location.href = `/admin/vendor-products/${productId}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Show remove confirmation modal
|
||||
*/
|
||||
confirmRemove(product) {
|
||||
this.productToRemove = product;
|
||||
this.showRemoveModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute product removal from catalog
|
||||
*/
|
||||
async executeRemove() {
|
||||
if (!this.productToRemove) return;
|
||||
|
||||
this.removing = true;
|
||||
try {
|
||||
await apiClient.delete(`/admin/vendor-products/${this.productToRemove.id}`);
|
||||
|
||||
adminVendorProductsLog.info('Removed product:', this.productToRemove.id);
|
||||
|
||||
// Close modal and refresh
|
||||
this.showRemoveModal = false;
|
||||
this.productToRemove = null;
|
||||
|
||||
// Show success notification
|
||||
Utils.showToast('Product removed from vendor catalog.', 'success');
|
||||
|
||||
// Refresh the list
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
adminVendorProductsLog.error('Failed to remove product:', error);
|
||||
this.error = error.message || 'Failed to remove product';
|
||||
} finally {
|
||||
this.removing = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format price for display
|
||||
*/
|
||||
formatPrice(price, currency = 'EUR') {
|
||||
if (price === null || price === undefined) return '-';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'EUR'
|
||||
}).format(price);
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Previous page
|
||||
*/
|
||||
previousPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.pagination.page--;
|
||||
this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Next page
|
||||
*/
|
||||
nextPage() {
|
||||
if (this.pagination.page < this.totalPages) {
|
||||
this.pagination.page++;
|
||||
this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Go to specific page
|
||||
*/
|
||||
goToPage(pageNum) {
|
||||
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
|
||||
this.pagination.page = pageNum;
|
||||
this.loadProducts();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
114
app/modules/catalog/static/vendor/js/product-create.js
vendored
Normal file
114
app/modules/catalog/static/vendor/js/product-create.js
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
// app/modules/catalog/static/vendor/js/product-create.js
|
||||
/**
|
||||
* Vendor product creation page logic
|
||||
*/
|
||||
|
||||
const vendorProductCreateLog = window.LogConfig.loggers.vendorProductCreate ||
|
||||
window.LogConfig.createLogger('vendorProductCreate', false);
|
||||
|
||||
vendorProductCreateLog.info('Loading...');
|
||||
|
||||
function vendorProductCreate() {
|
||||
vendorProductCreateLog.info('vendorProductCreate() called');
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'products',
|
||||
|
||||
// Back URL
|
||||
get backUrl() {
|
||||
return `/vendor/${this.vendorCode}/products`;
|
||||
},
|
||||
|
||||
// Loading states
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
|
||||
// Form data
|
||||
form: {
|
||||
title: '',
|
||||
brand: '',
|
||||
vendor_sku: '',
|
||||
gtin: '',
|
||||
price: '',
|
||||
currency: 'EUR',
|
||||
availability: 'in_stock',
|
||||
is_active: true,
|
||||
is_featured: false,
|
||||
is_digital: false,
|
||||
description: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Guard against duplicate initialization
|
||||
if (window._vendorProductCreateInitialized) return;
|
||||
window._vendorProductCreateInitialized = true;
|
||||
|
||||
vendorProductCreateLog.info('Initializing product create page...');
|
||||
|
||||
try {
|
||||
// IMPORTANT: Call parent init first to set vendorCode from URL
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
vendorProductCreateLog.info('Product create page initialized');
|
||||
} catch (err) {
|
||||
vendorProductCreateLog.error('Failed to initialize:', err);
|
||||
this.error = err.message || 'Failed to initialize';
|
||||
}
|
||||
},
|
||||
|
||||
async createProduct() {
|
||||
if (!this.form.title || !this.form.price) {
|
||||
this.showToast('Title and price are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
// Create product directly (vendor_id from JWT token)
|
||||
const response = await apiClient.post('/vendor/products/create', {
|
||||
title: this.form.title,
|
||||
brand: this.form.brand || null,
|
||||
vendor_sku: this.form.vendor_sku || null,
|
||||
gtin: this.form.gtin || null,
|
||||
price: parseFloat(this.form.price),
|
||||
currency: this.form.currency,
|
||||
availability: this.form.availability,
|
||||
is_active: this.form.is_active,
|
||||
is_featured: this.form.is_featured,
|
||||
description: this.form.description || null
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.message || 'Failed to create product');
|
||||
}
|
||||
|
||||
vendorProductCreateLog.info('Product created:', response.data);
|
||||
this.showToast('Product created successfully', 'success');
|
||||
|
||||
// Navigate back to products list
|
||||
setTimeout(() => {
|
||||
window.location.href = this.backUrl;
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
vendorProductCreateLog.error('Failed to create product:', err);
|
||||
this.error = err.message || 'Failed to create product';
|
||||
this.showToast(this.error, 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
vendorProductCreateLog.info('Loaded successfully');
|
||||
548
app/modules/catalog/static/vendor/js/products.js
vendored
Normal file
548
app/modules/catalog/static/vendor/js/products.js
vendored
Normal file
@@ -0,0 +1,548 @@
|
||||
// app/modules/catalog/static/vendor/js/products.js
|
||||
/**
|
||||
* Vendor products management page logic
|
||||
* View, edit, and manage vendor's product catalog
|
||||
*/
|
||||
|
||||
const vendorProductsLog = window.LogConfig.loggers.vendorProducts ||
|
||||
window.LogConfig.createLogger('vendorProducts', false);
|
||||
|
||||
vendorProductsLog.info('Loading...');
|
||||
|
||||
function vendorProducts() {
|
||||
vendorProductsLog.info('vendorProducts() called');
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'products',
|
||||
|
||||
// Loading states
|
||||
loading: true,
|
||||
error: '',
|
||||
saving: false,
|
||||
|
||||
// Products data
|
||||
products: [],
|
||||
stats: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
featured: 0
|
||||
},
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
search: '',
|
||||
status: '', // 'active', 'inactive', ''
|
||||
featured: '' // 'true', 'false', ''
|
||||
},
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
|
||||
// Modal states
|
||||
showDeleteModal: false,
|
||||
showDetailModal: false,
|
||||
showBulkDeleteModal: false,
|
||||
selectedProduct: null,
|
||||
|
||||
// Bulk selection
|
||||
selectedProducts: [],
|
||||
|
||||
// Debounce timer
|
||||
searchTimeout: null,
|
||||
|
||||
// Computed: Total pages
|
||||
get totalPages() {
|
||||
return this.pagination.pages;
|
||||
},
|
||||
|
||||
// Computed: Start index for pagination display
|
||||
get startIndex() {
|
||||
if (this.pagination.total === 0) return 0;
|
||||
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
||||
},
|
||||
|
||||
// Computed: End index for pagination display
|
||||
get endIndex() {
|
||||
const end = this.pagination.page * this.pagination.per_page;
|
||||
return end > this.pagination.total ? this.pagination.total : end;
|
||||
},
|
||||
|
||||
// Computed: Page numbers for pagination
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
const totalPages = this.totalPages;
|
||||
const current = this.pagination.page;
|
||||
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (current > 3) pages.push('...');
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(totalPages - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (current < totalPages - 2) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
|
||||
// Computed: Check if all visible products are selected
|
||||
get allSelected() {
|
||||
return this.products.length > 0 && this.selectedProducts.length === this.products.length;
|
||||
},
|
||||
|
||||
// Computed: Check if some but not all products are selected
|
||||
get someSelected() {
|
||||
return this.selectedProducts.length > 0 && this.selectedProducts.length < this.products.length;
|
||||
},
|
||||
|
||||
async init() {
|
||||
vendorProductsLog.info('Products init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._vendorProductsInitialized) {
|
||||
vendorProductsLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._vendorProductsInitialized = true;
|
||||
|
||||
// IMPORTANT: Call parent init first to set vendorCode from URL
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
// Load platform settings for rows per page
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadProducts();
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Init failed:', error);
|
||||
this.error = 'Failed to initialize products page';
|
||||
}
|
||||
|
||||
vendorProductsLog.info('Products initialization complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Load products with filtering and pagination
|
||||
*/
|
||||
async loadProducts() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.pagination.page - 1) * this.pagination.per_page,
|
||||
limit: this.pagination.per_page
|
||||
});
|
||||
|
||||
// Add filters
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
if (this.filters.status) {
|
||||
params.append('is_active', this.filters.status === 'active');
|
||||
}
|
||||
if (this.filters.featured) {
|
||||
params.append('is_featured', this.filters.featured === 'true');
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/vendor/products?${params.toString()}`);
|
||||
|
||||
this.products = response.products || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
|
||||
|
||||
// Calculate stats from response or products
|
||||
this.stats = {
|
||||
total: response.total || this.products.length,
|
||||
active: this.products.filter(p => p.is_active).length,
|
||||
inactive: this.products.filter(p => !p.is_active).length,
|
||||
featured: this.products.filter(p => p.is_featured).length
|
||||
};
|
||||
|
||||
vendorProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total);
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Failed to load products:', error);
|
||||
this.error = error.message || 'Failed to load products';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Debounced search handler
|
||||
*/
|
||||
debouncedSearch() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply filter and reload
|
||||
*/
|
||||
applyFilter() {
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
clearFilters() {
|
||||
this.filters = {
|
||||
search: '',
|
||||
status: '',
|
||||
featured: ''
|
||||
};
|
||||
this.pagination.page = 1;
|
||||
this.loadProducts();
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle product active status
|
||||
*/
|
||||
async toggleActive(product) {
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.put(`/vendor/products/${product.id}/toggle-active`);
|
||||
product.is_active = !product.is_active;
|
||||
Utils.showToast(
|
||||
product.is_active ? 'Product activated' : 'Product deactivated',
|
||||
'success'
|
||||
);
|
||||
vendorProductsLog.info('Toggled product active:', product.id, product.is_active);
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Failed to toggle active:', error);
|
||||
Utils.showToast(error.message || 'Failed to update product', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle product featured status
|
||||
*/
|
||||
async toggleFeatured(product) {
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.put(`/vendor/products/${product.id}/toggle-featured`);
|
||||
product.is_featured = !product.is_featured;
|
||||
Utils.showToast(
|
||||
product.is_featured ? 'Product marked as featured' : 'Product unmarked as featured',
|
||||
'success'
|
||||
);
|
||||
vendorProductsLog.info('Toggled product featured:', product.id, product.is_featured);
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Failed to toggle featured:', error);
|
||||
Utils.showToast(error.message || 'Failed to update product', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* View product details
|
||||
*/
|
||||
viewProduct(product) {
|
||||
this.selectedProduct = product;
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm delete product
|
||||
*/
|
||||
confirmDelete(product) {
|
||||
this.selectedProduct = product;
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute delete product
|
||||
*/
|
||||
async deleteProduct() {
|
||||
if (!this.selectedProduct) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.delete(`/vendor/products/${this.selectedProduct.id}`);
|
||||
Utils.showToast('Product deleted successfully', 'success');
|
||||
vendorProductsLog.info('Deleted product:', this.selectedProduct.id);
|
||||
|
||||
this.showDeleteModal = false;
|
||||
this.selectedProduct = null;
|
||||
await this.loadProducts();
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Failed to delete product:', error);
|
||||
Utils.showToast(error.message || 'Failed to delete product', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to edit product page
|
||||
*/
|
||||
editProduct(product) {
|
||||
window.location.href = `/vendor/${this.vendorCode}/products/${product.id}/edit`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to create product page
|
||||
*/
|
||||
createProduct() {
|
||||
window.location.href = `/vendor/${this.vendorCode}/products/create`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Format price for display
|
||||
*/
|
||||
formatPrice(cents) {
|
||||
if (!cents && cents !== 0) return '-';
|
||||
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
|
||||
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(cents / 100);
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Previous page
|
||||
*/
|
||||
previousPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.pagination.page--;
|
||||
this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Next page
|
||||
*/
|
||||
nextPage() {
|
||||
if (this.pagination.page < this.totalPages) {
|
||||
this.pagination.page++;
|
||||
this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pagination: Go to specific page
|
||||
*/
|
||||
goToPage(pageNum) {
|
||||
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
|
||||
this.pagination.page = pageNum;
|
||||
this.loadProducts();
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// BULK OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Toggle select all products on current page
|
||||
*/
|
||||
toggleSelectAll() {
|
||||
if (this.allSelected) {
|
||||
this.selectedProducts = [];
|
||||
} else {
|
||||
this.selectedProducts = this.products.map(p => p.id);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle selection of a single product
|
||||
*/
|
||||
toggleSelect(productId) {
|
||||
const index = this.selectedProducts.indexOf(productId);
|
||||
if (index === -1) {
|
||||
this.selectedProducts.push(productId);
|
||||
} else {
|
||||
this.selectedProducts.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if product is selected
|
||||
*/
|
||||
isSelected(productId) {
|
||||
return this.selectedProducts.includes(productId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all selections
|
||||
*/
|
||||
clearSelection() {
|
||||
this.selectedProducts = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk activate selected products
|
||||
*/
|
||||
async bulkActivate() {
|
||||
if (this.selectedProducts.length === 0) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
let successCount = 0;
|
||||
for (const productId of this.selectedProducts) {
|
||||
const product = this.products.find(p => p.id === productId);
|
||||
if (product && !product.is_active) {
|
||||
await apiClient.put(`/vendor/products/${productId}/toggle-active`);
|
||||
product.is_active = true;
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) activated`, 'success');
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Bulk activate failed:', error);
|
||||
Utils.showToast(error.message || 'Failed to activate products', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk deactivate selected products
|
||||
*/
|
||||
async bulkDeactivate() {
|
||||
if (this.selectedProducts.length === 0) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
let successCount = 0;
|
||||
for (const productId of this.selectedProducts) {
|
||||
const product = this.products.find(p => p.id === productId);
|
||||
if (product && product.is_active) {
|
||||
await apiClient.put(`/vendor/products/${productId}/toggle-active`);
|
||||
product.is_active = false;
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) deactivated`, 'success');
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Bulk deactivate failed:', error);
|
||||
Utils.showToast(error.message || 'Failed to deactivate products', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk set featured on selected products
|
||||
*/
|
||||
async bulkSetFeatured() {
|
||||
if (this.selectedProducts.length === 0) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
let successCount = 0;
|
||||
for (const productId of this.selectedProducts) {
|
||||
const product = this.products.find(p => p.id === productId);
|
||||
if (product && !product.is_featured) {
|
||||
await apiClient.put(`/vendor/products/${productId}/toggle-featured`);
|
||||
product.is_featured = true;
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) marked as featured`, 'success');
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Bulk set featured failed:', error);
|
||||
Utils.showToast(error.message || 'Failed to update products', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk remove featured from selected products
|
||||
*/
|
||||
async bulkRemoveFeatured() {
|
||||
if (this.selectedProducts.length === 0) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
let successCount = 0;
|
||||
for (const productId of this.selectedProducts) {
|
||||
const product = this.products.find(p => p.id === productId);
|
||||
if (product && product.is_featured) {
|
||||
await apiClient.put(`/vendor/products/${productId}/toggle-featured`);
|
||||
product.is_featured = false;
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) unmarked as featured`, 'success');
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Bulk remove featured failed:', error);
|
||||
Utils.showToast(error.message || 'Failed to update products', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm bulk delete
|
||||
*/
|
||||
confirmBulkDelete() {
|
||||
if (this.selectedProducts.length === 0) return;
|
||||
this.showBulkDeleteModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute bulk delete
|
||||
*/
|
||||
async bulkDelete() {
|
||||
if (this.selectedProducts.length === 0) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
let successCount = 0;
|
||||
for (const productId of this.selectedProducts) {
|
||||
await apiClient.delete(`/vendor/products/${productId}`);
|
||||
successCount++;
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) deleted`, 'success');
|
||||
this.showBulkDeleteModal = false;
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (error) {
|
||||
vendorProductsLog.error('Bulk delete failed:', error);
|
||||
Utils.showToast(error.message || 'Failed to delete products', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user