- Add vendor selector component for admin pages - Add input macros for form handling - Add truck icon for shipping UI - Update vendor operations expansion plan - Update mkdocs configuration - Update dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
244 lines
8.7 KiB
JavaScript
244 lines
8.7 KiB
JavaScript
// static/shared/js/vendor-selector.js
|
|
/**
|
|
* Shared Vendor Selector Module
|
|
* =============================
|
|
* Provides a reusable Tom Select-based vendor autocomplete component.
|
|
*
|
|
* Features:
|
|
* - Async search with debouncing (150ms)
|
|
* - Searches by vendor name and code
|
|
* - Dark mode support
|
|
* - Caches recent searches
|
|
* - Graceful fallback if Tom Select not available
|
|
*
|
|
* Usage:
|
|
* // In Alpine.js component init():
|
|
* this.$nextTick(() => {
|
|
* this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, {
|
|
* onSelect: (vendor) => this.handleVendorSelect(vendor),
|
|
* onClear: () => this.handleVendorClear(),
|
|
* minChars: 2,
|
|
* maxOptions: 50
|
|
* });
|
|
* });
|
|
*
|
|
* // To programmatically set a value:
|
|
* this.vendorSelector.setValue(vendorId);
|
|
*
|
|
* // To clear:
|
|
* this.vendorSelector.clear();
|
|
*/
|
|
|
|
const vendorSelectorLog = window.LogConfig?.loggers?.vendorSelector ||
|
|
window.LogConfig?.createLogger?.('vendorSelector', false) ||
|
|
{ info: console.log, warn: console.warn, error: console.error };
|
|
|
|
/**
|
|
* Check if Tom Select is available, with retry logic
|
|
* @param {Function} callback - Called when Tom Select is available
|
|
* @param {number} maxRetries - Maximum retry attempts
|
|
* @param {number} retryDelay - Delay between retries in ms
|
|
*/
|
|
function waitForTomSelect(callback, maxRetries = 20, retryDelay = 100) {
|
|
let retries = 0;
|
|
|
|
function check() {
|
|
if (typeof TomSelect !== 'undefined') {
|
|
callback();
|
|
} else if (retries < maxRetries) {
|
|
retries++;
|
|
vendorSelectorLog.info(`Waiting for TomSelect... (attempt ${retries}/${maxRetries})`);
|
|
setTimeout(check, retryDelay);
|
|
} else {
|
|
vendorSelectorLog.error('TomSelect not available after maximum retries');
|
|
}
|
|
}
|
|
|
|
check();
|
|
}
|
|
|
|
/**
|
|
* Initialize a vendor selector on the given element
|
|
* @param {HTMLElement} selectElement - The select element to enhance
|
|
* @param {Object} options - Configuration options
|
|
* @param {Function} options.onSelect - Callback when vendor is selected (receives vendor object)
|
|
* @param {Function} options.onClear - Callback when selection is cleared
|
|
* @param {number} options.minChars - Minimum characters before search (default: 2)
|
|
* @param {number} options.maxOptions - Maximum options to show (default: 50)
|
|
* @param {string} options.placeholder - Placeholder text
|
|
* @param {string} options.apiEndpoint - API endpoint for search (default: '/api/v1/admin/vendors')
|
|
* @returns {Object} Controller object with setValue() and clear() methods
|
|
*/
|
|
function initVendorSelector(selectElement, options = {}) {
|
|
if (!selectElement) {
|
|
vendorSelectorLog.error('Vendor selector element not provided');
|
|
return null;
|
|
}
|
|
|
|
const config = {
|
|
minChars: options.minChars || 2,
|
|
maxOptions: options.maxOptions || 50,
|
|
placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search vendor by name or code...',
|
|
apiEndpoint: options.apiEndpoint || '/api/v1/admin/vendors',
|
|
onSelect: options.onSelect || (() => {}),
|
|
onClear: options.onClear || (() => {})
|
|
};
|
|
|
|
let tomSelectInstance = null;
|
|
|
|
// Controller object returned to caller
|
|
const controller = {
|
|
/**
|
|
* Set the selected vendor by ID
|
|
* @param {number} vendorId - Vendor ID to select
|
|
* @param {Object} vendorData - Optional vendor data to avoid API call
|
|
*/
|
|
setValue: async function(vendorId, vendorData = null) {
|
|
if (!tomSelectInstance) return;
|
|
|
|
if (vendorData) {
|
|
// Add option and set value
|
|
tomSelectInstance.addOption({
|
|
id: vendorData.id,
|
|
name: vendorData.name,
|
|
vendor_code: vendorData.vendor_code
|
|
});
|
|
tomSelectInstance.setValue(vendorData.id, true);
|
|
} else if (vendorId) {
|
|
// Fetch vendor data and set
|
|
try {
|
|
const response = await apiClient.get(`${config.apiEndpoint}/${vendorId}`);
|
|
tomSelectInstance.addOption({
|
|
id: response.id,
|
|
name: response.name,
|
|
vendor_code: response.vendor_code
|
|
});
|
|
tomSelectInstance.setValue(response.id, true);
|
|
} catch (error) {
|
|
vendorSelectorLog.error('Failed to load vendor:', error);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear the selection
|
|
*/
|
|
clear: function() {
|
|
if (tomSelectInstance) {
|
|
tomSelectInstance.clear();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get the Tom Select instance
|
|
*/
|
|
getInstance: function() {
|
|
return tomSelectInstance;
|
|
},
|
|
|
|
/**
|
|
* Destroy the Tom Select instance
|
|
*/
|
|
destroy: function() {
|
|
if (tomSelectInstance) {
|
|
tomSelectInstance.destroy();
|
|
tomSelectInstance = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Initialize Tom Select when available
|
|
waitForTomSelect(() => {
|
|
vendorSelectorLog.info('Initializing vendor selector');
|
|
|
|
tomSelectInstance = new TomSelect(selectElement, {
|
|
valueField: 'id',
|
|
labelField: 'name',
|
|
searchField: ['name', 'vendor_code'],
|
|
maxOptions: config.maxOptions,
|
|
placeholder: config.placeholder,
|
|
|
|
// Async search with debouncing
|
|
load: async function(query, callback) {
|
|
if (query.length < config.minChars) {
|
|
callback([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.get(
|
|
`${config.apiEndpoint}?search=${encodeURIComponent(query)}&limit=${config.maxOptions}`
|
|
);
|
|
|
|
const vendors = (response.vendors || []).map(v => ({
|
|
id: v.id,
|
|
name: v.name,
|
|
vendor_code: v.vendor_code
|
|
}));
|
|
|
|
vendorSelectorLog.info(`Found ${vendors.length} vendors for "${query}"`);
|
|
callback(vendors);
|
|
} catch (error) {
|
|
vendorSelectorLog.error('Vendor search failed:', error);
|
|
callback([]);
|
|
}
|
|
},
|
|
|
|
// Custom rendering
|
|
render: {
|
|
option: function(data, escape) {
|
|
return `<div class="flex justify-between items-center py-1">
|
|
<span class="font-medium">${escape(data.name)}</span>
|
|
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2 font-mono">${escape(data.vendor_code)}</span>
|
|
</div>`;
|
|
},
|
|
item: function(data, escape) {
|
|
return `<div class="flex items-center gap-2">
|
|
<span>${escape(data.name)}</span>
|
|
<span class="text-xs text-gray-400 font-mono">(${escape(data.vendor_code)})</span>
|
|
</div>`;
|
|
},
|
|
no_results: function() {
|
|
return '<div class="no-results py-2 px-3 text-gray-500 dark:text-gray-400">No vendors found</div>';
|
|
},
|
|
loading: function() {
|
|
return '<div class="loading py-2 px-3 text-gray-500 dark:text-gray-400">Searching...</div>';
|
|
}
|
|
},
|
|
|
|
// Event handlers
|
|
onChange: function(value) {
|
|
if (value) {
|
|
const selectedOption = this.options[value];
|
|
if (selectedOption) {
|
|
vendorSelectorLog.info('Vendor selected:', selectedOption);
|
|
config.onSelect({
|
|
id: parseInt(value),
|
|
name: selectedOption.name,
|
|
vendor_code: selectedOption.vendor_code
|
|
});
|
|
}
|
|
} else {
|
|
vendorSelectorLog.info('Vendor selection cleared');
|
|
config.onClear();
|
|
}
|
|
},
|
|
|
|
// Performance settings
|
|
loadThrottle: 150, // Debounce search requests
|
|
closeAfterSelect: true,
|
|
hideSelected: false,
|
|
persist: true, // Cache options
|
|
createOnBlur: false,
|
|
create: false
|
|
});
|
|
|
|
vendorSelectorLog.info('Vendor selector initialized');
|
|
});
|
|
|
|
return controller;
|
|
}
|
|
|
|
// Export to window for global access
|
|
window.initVendorSelector = initVendorSelector;
|