Files
orion/static/vendor/js/inventory.js
Samir Boulahtit 36603178c3 feat: add email settings with database overrides for admin and vendor
Platform Email Settings (Admin):
- Add GET/PUT/DELETE /admin/settings/email/* endpoints
- Settings stored in admin_settings table override .env values
- Support all providers: SMTP, SendGrid, Mailgun, Amazon SES
- Edit mode UI with provider-specific configuration forms
- Reset to .env defaults functionality
- Test email to verify configuration

Vendor Email Settings:
- Add VendorEmailSettings model with one-to-one vendor relationship
- Migration: v0a1b2c3d4e5_add_vendor_email_settings.py
- Service: vendor_email_settings_service.py with tier validation
- API endpoints: /vendor/email-settings/* (CRUD, status, verify)
- Email tab in vendor settings page with full configuration
- Warning banner until email is configured (like billing warnings)
- Premium providers (SendGrid, Mailgun, SES) tier-gated to Business+

Email Service Updates:
- get_platform_email_config(db) checks DB first, then .env
- Configurable provider classes accept config dict
- EmailService uses database-aware providers
- Vendor emails use vendor's own SMTP (Wizamart doesn't pay)
- "Powered by Wizamart" footer for Essential/Professional tiers
- White-label (no footer) for Business/Enterprise tiers

Other:
- Add scripts/install.py for first-time platform setup
- Add make install target
- Update init-prod to include email template seeding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:23:47 +01:00

515 lines
16 KiB
JavaScript

// static/vendor/js/inventory.js
/**
* Vendor inventory management page logic
* View and manage stock levels
*/
const vendorInventoryLog = window.LogConfig.loggers.vendorInventory ||
window.LogConfig.createLogger('vendorInventory', false);
vendorInventoryLog.info('Loading...');
function vendorInventory() {
vendorInventoryLog.info('vendorInventory() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'inventory',
// Loading states
loading: true,
error: '',
saving: false,
// Inventory data
inventory: [],
stats: {
total_entries: 0,
total_quantity: 0,
low_stock_count: 0,
out_of_stock_count: 0
},
// Filters
filters: {
search: '',
location: '',
low_stock: ''
},
// Available locations for filter dropdown
locations: [],
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// Modal states
showAdjustModal: false,
showSetModal: false,
selectedItem: null,
// Form data
adjustForm: {
quantity: 0,
reason: ''
},
setForm: {
quantity: 0
},
// Bulk operations
selectedItems: [],
showBulkAdjustModal: false,
bulkAdjustForm: {
quantity: 0,
reason: ''
},
// 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 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');
// Guard against multiple initialization
if (window._vendorInventoryInitialized) {
vendorInventoryLog.warn('Already initialized, skipping');
return;
}
window._vendorInventoryInitialized = 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.loadInventory();
} catch (error) {
vendorInventoryLog.error('Init failed:', error);
this.error = 'Failed to initialize inventory page';
}
vendorInventoryLog.info('Inventory initialization complete');
},
/**
* Load inventory with filtering and pagination
*/
async loadInventory() {
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.location) {
params.append('location', this.filters.location);
}
if (this.filters.low_stock) {
params.append('low_stock', this.filters.low_stock);
}
const response = await apiClient.get(`/vendor/inventory?${params.toString()}`);
this.inventory = response.items || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
// Extract unique locations
this.extractLocations();
// Calculate stats
this.calculateStats();
vendorInventoryLog.info('Loaded inventory:', this.inventory.length, 'of', this.pagination.total);
} catch (error) {
vendorInventoryLog.error('Failed to load inventory:', error);
this.error = error.message || 'Failed to load inventory';
} finally {
this.loading = false;
}
},
/**
* Extract unique locations from inventory
*/
extractLocations() {
const locationSet = new Set(this.inventory.map(i => i.location).filter(Boolean));
this.locations = Array.from(locationSet).sort();
},
/**
* Calculate inventory statistics
*/
calculateStats() {
this.stats = {
total_entries: this.pagination.total,
total_quantity: this.inventory.reduce((sum, i) => sum + (i.quantity || 0), 0),
low_stock_count: this.inventory.filter(i => i.quantity > 0 && i.quantity <= (i.low_stock_threshold || 5)).length,
out_of_stock_count: this.inventory.filter(i => i.quantity <= 0).length
};
},
/**
* Debounced search handler
*/
debouncedSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadInventory();
}, 300);
},
/**
* Apply filter and reload
*/
applyFilter() {
this.pagination.page = 1;
this.loadInventory();
},
/**
* Clear all filters
*/
clearFilters() {
this.filters = {
search: '',
location: '',
low_stock: ''
};
this.pagination.page = 1;
this.loadInventory();
},
/**
* Open adjust stock modal
*/
openAdjustModal(item) {
this.selectedItem = item;
this.adjustForm = {
quantity: 0,
reason: ''
};
this.showAdjustModal = true;
},
/**
* Open set quantity modal
*/
openSetModal(item) {
this.selectedItem = item;
this.setForm = {
quantity: item.quantity || 0
};
this.showSetModal = true;
},
/**
* Execute stock adjustment
*/
async executeAdjust() {
if (!this.selectedItem || this.adjustForm.quantity === 0) return;
this.saving = true;
try {
await apiClient.post(`/vendor/inventory/adjust`, {
product_id: this.selectedItem.product_id,
location: this.selectedItem.location,
quantity: this.adjustForm.quantity,
reason: this.adjustForm.reason || null
});
vendorInventoryLog.info('Adjusted inventory:', this.selectedItem.id);
this.showAdjustModal = false;
this.selectedItem = null;
Utils.showToast('Stock adjusted successfully', 'success');
await this.loadInventory();
} catch (error) {
vendorInventoryLog.error('Failed to adjust inventory:', error);
Utils.showToast(error.message || 'Failed to adjust stock', 'error');
} finally {
this.saving = false;
}
},
/**
* Execute set quantity
*/
async executeSet() {
if (!this.selectedItem || this.setForm.quantity < 0) return;
this.saving = true;
try {
await apiClient.post(`/vendor/inventory/set`, {
product_id: this.selectedItem.product_id,
location: this.selectedItem.location,
quantity: this.setForm.quantity
});
vendorInventoryLog.info('Set inventory quantity:', this.selectedItem.id);
this.showSetModal = false;
this.selectedItem = null;
Utils.showToast('Quantity set successfully', 'success');
await this.loadInventory();
} catch (error) {
vendorInventoryLog.error('Failed to set inventory:', error);
Utils.showToast(error.message || 'Failed to set quantity', 'error');
} finally {
this.saving = false;
}
},
/**
* Get stock status class
*/
getStockStatus(item) {
if (item.quantity <= 0) return 'out';
if (item.quantity <= (item.low_stock_threshold || 5)) return 'low';
return 'ok';
},
/**
* Format number with locale
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
return new Intl.NumberFormat(locale).format(num);
},
/**
* Pagination: Previous page
*/
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
this.loadInventory();
}
},
/**
* Pagination: Next page
*/
nextPage() {
if (this.pagination.page < this.totalPages) {
this.pagination.page++;
this.loadInventory();
}
},
/**
* Pagination: Go to specific page
*/
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
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/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');
}
};
}