chore: add shared components and update docs

- 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>
This commit is contained in:
2025-12-18 21:05:50 +01:00
parent 6c6b5d259d
commit f3dc143f1d
7 changed files with 383 additions and 22 deletions

View File

@@ -71,6 +71,7 @@ const Icons = {
'receipt': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2zM10 8.5a.5.5 0 11-1 0 .5.5 0 011 0zm5 5a.5.5 0 11-1 0 .5.5 0 011 0z"/></svg>`,
// Inventory & Products
'archive': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>`,
'cube': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>`,
'collection': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>`,
'photograph': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>`,

View File

@@ -0,0 +1,243 @@
// 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;