feat: add admin inventory management (Phase 1)

- Add admin API endpoints for inventory management
- Add inventory page with vendor selector and filtering
- Add admin schemas for cross-vendor inventory operations
- Support digital products with unlimited inventory
- Add integration tests for admin inventory API
- Add inventory management guide documentation

Mirrors vendor inventory functionality with admin-level access.

🤖 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:12 +01:00
parent 0ab10128ae
commit 8d8d41808b
12 changed files with 2880 additions and 3 deletions

View File

@@ -67,7 +67,9 @@ function data() {
'marketplace-products': 'vendorOps',
'vendor-products': 'vendorOps',
customers: 'vendorOps',
// Future: inventory, orders, shipping will map to 'vendorOps'
inventory: 'vendorOps',
orders: 'vendorOps',
// Future: shipping will map to 'vendorOps'
// Marketplace
'marketplace-letzshop': 'marketplace',
// Content Management

View File

@@ -0,0 +1,426 @@
// static/admin/js/inventory.js
/**
* Admin inventory management page logic
* View and manage stock levels across all vendors
*/
const adminInventoryLog = window.LogConfig.loggers.adminInventory ||
window.LogConfig.createLogger('adminInventory', false);
adminInventoryLog.info('Loading...');
function adminInventory() {
adminInventoryLog.info('adminInventory() 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,
total_reserved: 0,
total_available: 0,
low_stock_count: 0,
vendors_with_inventory: 0,
unique_locations: 0
},
// Filters
filters: {
search: '',
vendor_id: '',
location: '',
low_stock: ''
},
// Available locations for filter dropdown
locations: [],
// Vendor selector controller (Tom Select)
vendorSelector: null,
// Pagination
pagination: {
page: 1,
per_page: 50,
total: 0,
pages: 0
},
// Modal states
showAdjustModal: false,
showSetModal: false,
showDeleteModal: false,
selectedItem: null,
// Form data
adjustForm: {
quantity: 0,
reason: ''
},
setForm: {
quantity: 0
},
// 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;
},
async init() {
adminInventoryLog.info('Inventory init() called');
// Guard against multiple initialization
if (window._adminInventoryInitialized) {
adminInventoryLog.warn('Already initialized, skipping');
return;
}
window._adminInventoryInitialized = true;
// Initialize vendor selector (Tom Select)
this.$nextTick(() => {
this.initVendorSelector();
});
// Load data in parallel
await Promise.all([
this.loadStats(),
this.loadLocations(),
this.loadInventory()
]);
adminInventoryLog.info('Inventory initialization complete');
},
/**
* Initialize vendor selector with Tom Select
*/
initVendorSelector() {
if (!this.$refs.vendorSelect) {
adminInventoryLog.warn('Vendor select element not found');
return;
}
this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, {
placeholder: 'Filter by vendor...',
onSelect: (vendor) => {
adminInventoryLog.info('Vendor selected:', vendor);
this.filters.vendor_id = vendor.id;
this.pagination.page = 1;
this.loadLocations();
this.loadInventory();
},
onClear: () => {
adminInventoryLog.info('Vendor filter cleared');
this.filters.vendor_id = '';
this.pagination.page = 1;
this.loadLocations();
this.loadInventory();
}
});
},
/**
* Load inventory statistics
*/
async loadStats() {
try {
const response = await apiClient.get('/admin/inventory/stats');
this.stats = response;
adminInventoryLog.info('Loaded stats:', this.stats);
} catch (error) {
adminInventoryLog.error('Failed to load stats:', error);
}
},
/**
* Load available locations for filter
*/
async loadLocations() {
try {
const params = this.filters.vendor_id ? `?vendor_id=${this.filters.vendor_id}` : '';
const response = await apiClient.get(`/admin/inventory/locations${params}`);
this.locations = response.locations || [];
adminInventoryLog.info('Loaded locations:', this.locations.length);
} catch (error) {
adminInventoryLog.error('Failed to load locations:', error);
}
},
/**
* 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.vendor_id) {
params.append('vendor_id', this.filters.vendor_id);
}
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(`/admin/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);
adminInventoryLog.info('Loaded inventory:', this.inventory.length, 'of', this.pagination.total);
} catch (error) {
adminInventoryLog.error('Failed to load inventory:', error);
this.error = error.message || 'Failed to load inventory';
} finally {
this.loading = false;
}
},
/**
* Debounced search handler
*/
debouncedSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadInventory();
}, 300);
},
/**
* Refresh inventory list
*/
async refresh() {
await Promise.all([
this.loadStats(),
this.loadLocations(),
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
};
this.showSetModal = true;
},
/**
* Confirm delete
*/
confirmDelete(item) {
this.selectedItem = item;
this.showDeleteModal = true;
},
/**
* Execute stock adjustment
*/
async executeAdjust() {
if (!this.selectedItem || this.adjustForm.quantity === 0) return;
this.saving = true;
try {
await apiClient.post('/admin/inventory/adjust', {
vendor_id: this.selectedItem.vendor_id,
product_id: this.selectedItem.product_id,
location: this.selectedItem.location,
quantity: this.adjustForm.quantity,
reason: this.adjustForm.reason || null
});
adminInventoryLog.info('Adjusted inventory:', this.selectedItem.id);
this.showAdjustModal = false;
this.selectedItem = null;
Utils.showToast('Stock adjusted successfully.', 'success');
await this.refresh();
} catch (error) {
adminInventoryLog.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('/admin/inventory/set', {
vendor_id: this.selectedItem.vendor_id,
product_id: this.selectedItem.product_id,
location: this.selectedItem.location,
quantity: this.setForm.quantity
});
adminInventoryLog.info('Set inventory quantity:', this.selectedItem.id);
this.showSetModal = false;
this.selectedItem = null;
Utils.showToast('Quantity set successfully.', 'success');
await this.refresh();
} catch (error) {
adminInventoryLog.error('Failed to set inventory:', error);
Utils.showToast(error.message || 'Failed to set quantity.', 'error');
} finally {
this.saving = false;
}
},
/**
* Execute delete
*/
async executeDelete() {
if (!this.selectedItem) return;
this.saving = true;
try {
await apiClient.delete(`/admin/inventory/${this.selectedItem.id}`);
adminInventoryLog.info('Deleted inventory:', this.selectedItem.id);
this.showDeleteModal = false;
this.selectedItem = null;
Utils.showToast('Inventory entry deleted.', 'success');
await this.refresh();
} catch (error) {
adminInventoryLog.error('Failed to delete inventory:', error);
Utils.showToast(error.message || 'Failed to delete entry.', 'error');
} finally {
this.saving = false;
}
},
/**
* Format number with locale
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').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();
}
}
};
}