// 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 }; // noqa: js-001 - fallback if logger not ready /** * 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: '/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 || '/admin/vendors', // Note: apiClient adds /api/v1 prefix 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 `