diff --git a/app/templates/vendor/inventory.html b/app/templates/vendor/inventory.html index 649895c8..32ca3bd6 100644 --- a/app/templates/vendor/inventory.html +++ b/app/templates/vendor/inventory.html @@ -120,12 +120,54 @@ + +
+
+ + item(s) selected + + +
+
+ + +
+
+
+ @@ -136,7 +178,16 @@ -
+ + Product SKU Location
+

No inventory found

@@ -278,6 +329,43 @@
+ + +{{ modal_simple( + show_var='showBulkAdjustModal', + title='Bulk Adjust Stock', + icon='plus-minus', + icon_color='blue', + confirm_text='Adjust All', + confirm_class='bg-purple-600 hover:bg-purple-700 focus:shadow-outline-purple', + confirm_fn='bulkAdjust()', + loading_var='saving' +) }} + {% endblock %} {% block extra_scripts %} diff --git a/app/templates/vendor/orders.html b/app/templates/vendor/orders.html index f315751c..be2b5287 100644 --- a/app/templates/vendor/orders.html +++ b/app/templates/vendor/orders.html @@ -126,12 +126,54 @@ + +
+
+ + order(s) selected + + +
+
+ + +
+
+
+ @@ -142,7 +184,16 @@ -
+ + Order # Customer Date
+

No orders found

@@ -251,6 +302,35 @@
+ + +{{ modal_simple( + show_var='showBulkStatusModal', + title='Bulk Update Status', + icon='pencil-square', + icon_color='blue', + confirm_text='Update All', + confirm_class='bg-purple-600 hover:bg-purple-700 focus:shadow-outline-purple', + confirm_fn='bulkUpdateStatus()', + loading_var='saving' +) }} + {% endblock %} {% block extra_scripts %} diff --git a/app/templates/vendor/products.html b/app/templates/vendor/products.html index 832adaf9..46639fd8 100644 --- a/app/templates/vendor/products.html +++ b/app/templates/vendor/products.html @@ -128,12 +128,78 @@ + +
+
+ + product(s) selected + + +
+
+ + + + + +
+
+
+ @@ -144,7 +210,16 @@ -
+ + Product SKU Price
+

No products found

@@ -272,6 +347,24 @@ This action cannot be undone.

+ + +{{ modal_simple( + show_var='showBulkDeleteModal', + title='Delete Selected Products', + icon='exclamation-triangle', + icon_color='red', + confirm_text='Delete All', + confirm_class='bg-red-600 hover:bg-red-700 focus:shadow-outline-red', + confirm_fn='bulkDelete()', + loading_var='saving' +) }} + {% endblock %} {% block extra_scripts %} diff --git a/docs/implementation/vendor-frontend-parity-plan.md b/docs/implementation/vendor-frontend-parity-plan.md index 8b720e5c..eebd87b3 100644 --- a/docs/implementation/vendor-frontend-parity-plan.md +++ b/docs/implementation/vendor-frontend-parity-plan.md @@ -1,11 +1,14 @@ # Vendor Frontend Parity Plan **Created:** January 1, 2026 -**Status:** In Progress +**Status:** Complete ## Executive Summary -The vendor frontend is now approximately 95% complete compared to admin. Phase 1 (Sidebar Refactor), Phase 2 (Core JS Files), and Phase 3 (Notifications + Analytics) are complete. Only bulk operations remain as optional enhancements. +The vendor frontend is now 100% complete compared to admin. All phases are finished: +- Phase 1: Sidebar Refactor +- Phase 2: Core JS Files +- Phase 3: New Features (Notifications, Analytics, Bulk Operations) --- @@ -69,18 +72,25 @@ Analytics --- -## Phase 3: New Features +## Phase 3: New Features ✅ COMPLETED ### Priority 3 (Medium) -- Add notifications center (page + JS) -- Add analytics/reports page -- Add bulk operations across pages +- ✅ Add notifications center (page + JS) +- ✅ Add analytics/reports page +- ✅ Add bulk operations across pages + +### Bulk Operations Implemented +| Page | Features | +|------|----------| +| Products | Select all, bulk activate/deactivate, bulk feature/unfeature, bulk delete, CSV export | +| Orders | Select all, bulk status update, CSV export | +| Inventory | Select all, bulk stock adjust, CSV export | ### Priority 4 (Low) -- Standardize API response handling -- Add loading states consistently -- Implement pagination for large lists -- Add confirmation dialogs +- ✅ Standardize API response handling +- ✅ Add loading states consistently +- ✅ Implement pagination for large lists +- ✅ Add confirmation dialogs --- @@ -101,6 +111,7 @@ Analytics | Content Pages | ✅ | ✅ | Complete | | Notifications | ✅ | ✅ | Complete | | Analytics | ✅ | ✅ | Complete | +| Bulk Operations | ✅ | ✅ | Complete | --- @@ -147,4 +158,4 @@ Analytics ### Phase 3: New Features ✅ - [x] Notifications center - [x] Analytics page -- [ ] Bulk operations (optional enhancement) +- [x] Bulk operations (products, orders, inventory) diff --git a/static/vendor/js/inventory.js b/static/vendor/js/inventory.js index b2d46a7a..e4b90496 100644 --- a/static/vendor/js/inventory.js +++ b/static/vendor/js/inventory.js @@ -65,6 +65,14 @@ function vendorInventory() { quantity: 0 }, + // Bulk operations + selectedItems: [], + showBulkAdjustModal: false, + bulkAdjustForm: { + quantity: 0, + reason: '' + }, + // Debounce timer searchTimeout: null, @@ -109,6 +117,16 @@ function vendorInventory() { return pages; }, + // Computed: Check if all visible items are selected + get allSelected() { + return this.inventory.length > 0 && this.selectedItems.length === this.inventory.length; + }, + + // Computed: Check if some but not all items are selected + get someSelected() { + return this.selectedItems.length > 0 && this.selectedItems.length < this.inventory.length; + }, + async init() { vendorInventoryLog.info('Inventory init() called'); @@ -360,6 +378,130 @@ function vendorInventory() { this.pagination.page = pageNum; this.loadInventory(); } + }, + + // ============================================================================ + // BULK OPERATIONS + // ============================================================================ + + /** + * Toggle select all items on current page + */ + toggleSelectAll() { + if (this.allSelected) { + this.selectedItems = []; + } else { + this.selectedItems = this.inventory.map(i => i.id); + } + }, + + /** + * Toggle selection of a single item + */ + toggleSelect(itemId) { + const index = this.selectedItems.indexOf(itemId); + if (index === -1) { + this.selectedItems.push(itemId); + } else { + this.selectedItems.splice(index, 1); + } + }, + + /** + * Check if item is selected + */ + isSelected(itemId) { + return this.selectedItems.includes(itemId); + }, + + /** + * Clear all selections + */ + clearSelection() { + this.selectedItems = []; + }, + + /** + * Open bulk adjust modal + */ + openBulkAdjustModal() { + if (this.selectedItems.length === 0) return; + this.bulkAdjustForm = { + quantity: 0, + reason: '' + }; + this.showBulkAdjustModal = true; + }, + + /** + * Execute bulk stock adjustment + */ + async bulkAdjust() { + if (this.selectedItems.length === 0 || this.bulkAdjustForm.quantity === 0) return; + + this.saving = true; + try { + let successCount = 0; + for (const itemId of this.selectedItems) { + const item = this.inventory.find(i => i.id === itemId); + if (item) { + try { + await apiClient.post(`/vendor/${this.vendorCode}/inventory/adjust`, { + product_id: item.product_id, + location: item.location, + quantity: this.bulkAdjustForm.quantity, + reason: this.bulkAdjustForm.reason || 'Bulk adjustment' + }); + successCount++; + } catch (error) { + vendorInventoryLog.warn(`Failed to adjust item ${itemId}:`, error); + } + } + } + Utils.showToast(`${successCount} item(s) adjusted by ${this.bulkAdjustForm.quantity > 0 ? '+' : ''}${this.bulkAdjustForm.quantity}`, 'success'); + this.showBulkAdjustModal = false; + this.clearSelection(); + await this.loadInventory(); + } catch (error) { + vendorInventoryLog.error('Bulk adjust failed:', error); + Utils.showToast(error.message || 'Failed to adjust inventory', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Export selected items as CSV + */ + exportSelectedItems() { + if (this.selectedItems.length === 0) return; + + const selectedData = this.inventory.filter(i => this.selectedItems.includes(i.id)); + + // Build CSV content + const headers = ['Product', 'SKU', 'Location', 'Quantity', 'Low Stock Threshold', 'Status']; + const rows = selectedData.map(i => [ + i.product_name || '-', + i.sku || '-', + i.location || 'Default', + i.quantity || 0, + i.low_stock_threshold || 5, + this.getStockStatus(i) + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + // Download + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `inventory_export_${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + + Utils.showToast(`Exported ${selectedData.length} item(s)`, 'success'); } }; } diff --git a/static/vendor/js/orders.js b/static/vendor/js/orders.js index 669db1a0..b5b4b9ed 100644 --- a/static/vendor/js/orders.js +++ b/static/vendor/js/orders.js @@ -64,8 +64,13 @@ function vendorOrders() { // Modal states showDetailModal: false, showStatusModal: false, + showBulkStatusModal: false, selectedOrder: null, newStatus: '', + bulkStatus: '', + + // Bulk selection + selectedOrders: [], // Debounce timer searchTimeout: null, @@ -111,6 +116,16 @@ function vendorOrders() { return pages; }, + // Computed: Check if all visible orders are selected + get allSelected() { + return this.orders.length > 0 && this.selectedOrders.length === this.orders.length; + }, + + // Computed: Check if some but not all orders are selected + get someSelected() { + return this.selectedOrders.length > 0 && this.selectedOrders.length < this.orders.length; + }, + async init() { vendorOrdersLog.info('Orders init() called'); @@ -349,6 +364,120 @@ function vendorOrders() { this.pagination.page = pageNum; this.loadOrders(); } + }, + + // ============================================================================ + // BULK OPERATIONS + // ============================================================================ + + /** + * Toggle select all orders on current page + */ + toggleSelectAll() { + if (this.allSelected) { + this.selectedOrders = []; + } else { + this.selectedOrders = this.orders.map(o => o.id); + } + }, + + /** + * Toggle selection of a single order + */ + toggleSelect(orderId) { + const index = this.selectedOrders.indexOf(orderId); + if (index === -1) { + this.selectedOrders.push(orderId); + } else { + this.selectedOrders.splice(index, 1); + } + }, + + /** + * Check if order is selected + */ + isSelected(orderId) { + return this.selectedOrders.includes(orderId); + }, + + /** + * Clear all selections + */ + clearSelection() { + this.selectedOrders = []; + }, + + /** + * Open bulk status change modal + */ + openBulkStatusModal() { + if (this.selectedOrders.length === 0) return; + this.bulkStatus = ''; + this.showBulkStatusModal = true; + }, + + /** + * Execute bulk status update + */ + async bulkUpdateStatus() { + if (this.selectedOrders.length === 0 || !this.bulkStatus) return; + + this.saving = true; + try { + let successCount = 0; + for (const orderId of this.selectedOrders) { + try { + await apiClient.put(`/vendor/${this.vendorCode}/orders/${orderId}/status`, { + status: this.bulkStatus + }); + successCount++; + } catch (error) { + vendorOrdersLog.warn(`Failed to update order ${orderId}:`, error); + } + } + Utils.showToast(`${successCount} order(s) updated to ${this.getStatusLabel(this.bulkStatus)}`, 'success'); + this.showBulkStatusModal = false; + this.clearSelection(); + await this.loadOrders(); + } catch (error) { + vendorOrdersLog.error('Bulk status update failed:', error); + Utils.showToast(error.message || 'Failed to update orders', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Export selected orders as CSV + */ + exportSelectedOrders() { + if (this.selectedOrders.length === 0) return; + + const selectedOrderData = this.orders.filter(o => this.selectedOrders.includes(o.id)); + + // Build CSV content + const headers = ['Order ID', 'Date', 'Customer', 'Status', 'Total']; + const rows = selectedOrderData.map(o => [ + o.order_number || o.id, + this.formatDate(o.created_at), + o.customer_name || o.customer_email || '-', + this.getStatusLabel(o.status), + this.formatPrice(o.total) + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + // Download + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `orders_export_${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + + Utils.showToast(`Exported ${selectedOrderData.length} order(s)`, 'success'); } }; } diff --git a/static/vendor/js/products.js b/static/vendor/js/products.js index 6852c4ac..d31ad52c 100644 --- a/static/vendor/js/products.js +++ b/static/vendor/js/products.js @@ -51,8 +51,12 @@ function vendorProducts() { // Modal states showDeleteModal: false, showDetailModal: false, + showBulkDeleteModal: false, selectedProduct: null, + // Bulk selection + selectedProducts: [], + // Debounce timer searchTimeout: null, @@ -97,6 +101,16 @@ function vendorProducts() { 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'); @@ -335,6 +349,192 @@ function vendorProducts() { 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/${this.vendorCode}/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/${this.vendorCode}/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/${this.vendorCode}/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/${this.vendorCode}/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/${this.vendorCode}/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; + } } }; }