diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html index 5cb894fe..42de3e54 100644 --- a/app/templates/admin/base.html +++ b/app/templates/admin/base.html @@ -25,6 +25,50 @@ [x-cloak] { display: none !important; } + + + + + {% block flatpickr_css %}{% endblock %} @@ -65,7 +109,25 @@ - + + + + + + + - + {% block chartjs_script %}{% endblock %} - + {% block flatpickr_script %}{% endblock %} - + {% block extra_scripts %}{% endblock %} \ No newline at end of file diff --git a/app/templates/shared/macros/inputs.html b/app/templates/shared/macros/inputs.html index 22fc0aad..338f041a 100644 --- a/app/templates/shared/macros/inputs.html +++ b/app/templates/shared/macros/inputs.html @@ -231,3 +231,54 @@ {% endmacro %} + + +{# + Vendor Selector (Tom Select) + ============================ + An async searchable vendor selector using Tom Select. + Searches vendors by name and code with autocomplete. + + Prerequisites: + - Tom Select CSS/JS must be loaded (included in admin/base.html) + - vendor-selector.js must be loaded + + Parameters: + - ref_name: Alpine.js x-ref name for the select element (default: 'vendorSelect') + - id: HTML id attribute (default: 'vendor-select') + - placeholder: Placeholder text (default: 'Search vendor by name or code...') + - width: CSS width class (default: 'w-80') + - on_init: JS callback name when Tom Select is initialized (optional) + + Usage: + {% from 'shared/macros/inputs.html' import vendor_selector %} + + {{ vendor_selector( + ref_name='vendorSelect', + placeholder='Select a vendor...', + width='w-96' + ) }} + + // In your Alpine.js component init(): + this.$nextTick(() => { + initVendorSelector(this.$refs.vendorSelect, { + onSelect: (vendor) => this.onVendorSelected(vendor), + onClear: () => this.onVendorCleared() + }); + }); +#} +{% macro vendor_selector( + ref_name='vendorSelect', + id='vendor-select', + placeholder='Search vendor by name or code...', + width='w-80' +) %} +
+ +
+{% endmacro %} diff --git a/docs/development/migration/vendor-operations-expansion.md b/docs/development/migration/vendor-operations-expansion.md index 524e2723..be40ac2d 100644 --- a/docs/development/migration/vendor-operations-expansion.md +++ b/docs/development/migration/vendor-operations-expansion.md @@ -19,8 +19,8 @@ The admin sidebar has a "Vendor Operations" section (formerly "Product Catalog") | Marketplace Products | ✅ Complete | View/manage products from marketplace imports | | Vendor Products | ✅ Complete | View/manage vendor-specific products | | Customers | ✅ Complete | Customer management (moved from Platform Admin) | -| Inventory | 🔲 Planned | Stock levels, adjustments, low-stock alerts | -| Orders | 🔲 Planned | Order management, status updates, fulfillment | +| Inventory | ✅ Complete | Admin API + UI with stock adjustments | +| Orders | ✅ Complete | Order management, status updates, fulfillment | | Shipping | 🔲 Planned | Tracking, carriers, shipping rules | --- @@ -249,21 +249,21 @@ CREATE TABLE shipping_rules ( - [x] Update Alpine.js configuration - [x] Create migration plan documentation -### Phase 2: Inventory -- [ ] Database migrations -- [ ] Create `InventoryService` -- [ ] API endpoints -- [ ] Admin inventory page -- [ ] Stock adjustment modal -- [ ] Low stock alerts +### Phase 2: Inventory ✅ +- [x] Database migrations (inventory table exists) +- [x] Create `InventoryService` +- [x] API endpoints +- [x] Admin inventory page +- [x] Stock adjustment modal +- [x] Low stock alerts (via filtering) -### Phase 3: Orders -- [ ] Database migrations -- [ ] Create `AdminOrderService` -- [ ] API endpoints -- [ ] Admin orders page -- [ ] Order detail view -- [ ] Status update functionality +### Phase 3: Orders ✅ +- [x] Database migrations (orders table exists) +- [x] Add admin methods to `OrderService` +- [x] API endpoints +- [x] Admin orders page +- [x] Order detail view +- [x] Status update functionality ### Phase 4: Shipping - [ ] Database migrations diff --git a/mkdocs.yml b/mkdocs.yml index 25cd49dc..67035bcc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -133,6 +133,8 @@ nav: - Multi-Marketplace Product Architecture: development/migration/multi-marketplace-product-architecture.md - Product Migration Database Changes: development/migration/product-migration-database-changes.md - Vendor Operations Expansion: development/migration/vendor-operations-expansion.md + - Implementation Plans: + - Admin Inventory Management: implementation/inventory-admin-migration.md - Seed Scripts Audit: development/seed-scripts-audit.md - Database Seeder: - Documentation: development/database-seeder/database-seeder-documentation.md @@ -186,6 +188,7 @@ nav: - User Guides: - User Management: guides/user-management.md - Product Management: guides/product-management.md + - Inventory Management: guides/inventory-management.md - Shop Setup: guides/shop-setup.md - CSV Import: guides/csv-import.md - Marketplace Integration: guides/marketplace-integration.md diff --git a/pyproject.toml b/pyproject.toml index 4a0328f1..c2f570b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,8 +142,9 @@ markers = [ "performance: marks tests as performance and load tests", "auth: marks tests as authentication and authorization tests", "products: marks tests as product management functionality", - "inventory: marks tests as inventory and inventory management", - "vendors: marks tests as vendor management functionality", + "inventory: marks tests as inventory and stock management", + "vendors: marks tests as vendor management (plural - admin context)", + "vendor: marks tests as vendor API tests (singular - vendor context)", "admin: marks tests as admin functionality and permissions", "marketplace: marks tests as marketplace import functionality", "stats: marks tests as statistics and reporting", diff --git a/static/shared/js/icons.js b/static/shared/js/icons.js index c8094100..616ad1ba 100644 --- a/static/shared/js/icons.js +++ b/static/shared/js/icons.js @@ -71,6 +71,7 @@ const Icons = { 'receipt': ``, // Inventory & Products + 'archive': ``, 'cube': ``, 'collection': ``, 'photograph': ``, diff --git a/static/shared/js/vendor-selector.js b/static/shared/js/vendor-selector.js new file mode 100644 index 00000000..df461d8b --- /dev/null +++ b/static/shared/js/vendor-selector.js @@ -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 `
+ ${escape(data.name)} + ${escape(data.vendor_code)} +
`; + }, + item: function(data, escape) { + return `
+ ${escape(data.name)} + (${escape(data.vendor_code)}) +
`; + }, + no_results: function() { + return '
No vendors found
'; + }, + loading: function() { + return '
Searching...
'; + } + }, + + // 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;