This commit introduces a comprehensive variant management system and enhances the existing ProductVariant model with per-variant weight overrides and stock tracking, integrated across Admin Panel and TeleBot. Features Added: - Variant Collections: Reusable variant templates (e.g., "Standard Sizes") - Admin UI for managing variant collections (CRUD operations) - Dynamic variant editor with JavaScript-based UI - Per-variant weight and weight unit overrides - Per-variant stock level tracking - SalesLedger model for financial tracking ProductVariant Enhancements: - Added Weight (decimal, nullable) field for variant-specific weights - Added WeightUnit (enum, nullable) field for variant-specific units - Maintains backward compatibility with product-level weights TeleBot Integration: - Enhanced variant selection UI to display stock levels - Shows weight information with proper unit conversion (µg, g, oz, lb, ml, L) - Compact button format: "Medium (15 in stock, 350g)" - Real-time stock availability display Database Migrations: - 20250928014850_AddVariantCollectionsAndSalesLedger - 20250928155814_AddWeightToProductVariants Technical Changes: - Updated Product model to support VariantCollectionId and VariantsJson - Extended ProductService with variant collection operations - Enhanced OrderService to handle variant-specific pricing and weights - Updated LittleShop.Client DTOs to match server models - Added JavaScript dynamic variant form builder Files Modified: 15 Files Added: 17 Lines Changed: ~2000 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
537 lines
23 KiB
JavaScript
537 lines
23 KiB
JavaScript
// Product Variants Management
|
|
// Handles dynamic variant input fields based on selected VariantCollection
|
|
|
|
class ProductVariantsManager {
|
|
constructor() {
|
|
this.variantCollectionSelect = document.getElementById('VariantCollectionId');
|
|
this.variantsJsonTextarea = document.getElementById('VariantsJson');
|
|
this.dynamicFieldsContainer = document.getElementById('dynamic-variant-fields');
|
|
this.advancedToggle = document.getElementById('toggle-advanced-variants');
|
|
this.advancedSection = document.getElementById('advanced-variant-section');
|
|
this.productStockInput = document.getElementById('StockQuantity');
|
|
this.productWeightUnitSelect = document.getElementById('WeightUnit');
|
|
|
|
if (!this.variantCollectionSelect || !this.variantsJsonTextarea || !this.dynamicFieldsContainer) {
|
|
console.error('ProductVariantsManager: Required elements not found');
|
|
return;
|
|
}
|
|
|
|
this.currentProperties = [];
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
// Listen for variant collection selection changes
|
|
this.variantCollectionSelect.addEventListener('change', (e) => {
|
|
this.handleVariantCollectionChange(e.target.value);
|
|
});
|
|
|
|
// Toggle advanced JSON editing
|
|
if (this.advancedToggle) {
|
|
this.advancedToggle.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
this.toggleAdvancedMode();
|
|
});
|
|
}
|
|
|
|
// Load existing variant data if in edit mode
|
|
if (this.variantCollectionSelect.value) {
|
|
this.handleVariantCollectionChange(this.variantCollectionSelect.value);
|
|
} else if (this.variantsJsonTextarea.value && this.variantsJsonTextarea.value.trim() !== '') {
|
|
// No collection selected but has JSON - show advanced mode
|
|
this.showAdvancedMode();
|
|
}
|
|
|
|
// Form submission handler
|
|
const form = this.variantCollectionSelect.closest('form');
|
|
if (form) {
|
|
form.addEventListener('submit', (e) => {
|
|
this.serializeVariantsToJson();
|
|
});
|
|
}
|
|
}
|
|
|
|
async handleVariantCollectionChange(collectionId) {
|
|
if (!collectionId || collectionId === '') {
|
|
this.clearDynamicFields();
|
|
this.currentProperties = [];
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/Admin/Products/GetVariantCollection?id=${collectionId}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const collection = await response.json();
|
|
console.log('Loaded variant collection:', collection);
|
|
console.log('PropertiesJson raw:', collection.propertiesJson);
|
|
|
|
let propertyDefinitions = {};
|
|
try {
|
|
const parsed = JSON.parse(collection.propertiesJson || '{}');
|
|
console.log('Parsed properties:', parsed);
|
|
console.log('Parsed type:', typeof parsed, 'Is array:', Array.isArray(parsed));
|
|
|
|
if (typeof parsed === 'object' && !Array.isArray(parsed) && parsed !== null) {
|
|
propertyDefinitions = parsed;
|
|
} else if (Array.isArray(parsed)) {
|
|
const converted = {};
|
|
parsed.forEach(item => {
|
|
if (typeof item === 'string') {
|
|
converted[item] = null;
|
|
} else if (typeof item === 'object' && item.name) {
|
|
converted[item.name] = item.values || null;
|
|
}
|
|
});
|
|
propertyDefinitions = converted;
|
|
} else {
|
|
console.error('Unexpected PropertiesJson format:', parsed);
|
|
propertyDefinitions = {};
|
|
}
|
|
|
|
console.log('Property definitions:', propertyDefinitions);
|
|
} catch (err) {
|
|
console.error('Error parsing PropertiesJson:', err);
|
|
propertyDefinitions = {};
|
|
}
|
|
|
|
this.currentProperties = propertyDefinitions;
|
|
|
|
let existingVariants = [];
|
|
if (this.variantsJsonTextarea.value && this.variantsJsonTextarea.value.trim() !== '') {
|
|
try {
|
|
existingVariants = JSON.parse(this.variantsJsonTextarea.value);
|
|
if (!Array.isArray(existingVariants)) {
|
|
existingVariants = [];
|
|
}
|
|
} catch (err) {
|
|
console.warn('Could not parse existing variants JSON:', err);
|
|
existingVariants = [];
|
|
}
|
|
}
|
|
|
|
this.generateDynamicFields(propertyDefinitions, existingVariants);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading variant collection:', error);
|
|
alert('Failed to load variant collection properties. Please try again.');
|
|
}
|
|
}
|
|
|
|
generateDynamicFields(propertyDefinitions, existingVariants = []) {
|
|
this.clearDynamicFields();
|
|
|
|
if (!propertyDefinitions || Object.keys(propertyDefinitions).length === 0) {
|
|
this.dynamicFieldsContainer.innerHTML = '<p class="text-muted"><i class="fas fa-info-circle"></i> This variant collection has no properties defined.</p>';
|
|
return;
|
|
}
|
|
|
|
let variantsToDisplay = existingVariants;
|
|
let isAutoGenerated = false;
|
|
|
|
if (!variantsToDisplay || variantsToDisplay.length === 0) {
|
|
variantsToDisplay = this.generateCombinations(propertyDefinitions);
|
|
isAutoGenerated = true;
|
|
console.log('Auto-generated combinations:', variantsToDisplay);
|
|
} else {
|
|
console.log('Using existing variants:', variantsToDisplay);
|
|
}
|
|
|
|
if (variantsToDisplay.length === 0) {
|
|
this.dynamicFieldsContainer.innerHTML = '<p class="text-muted"><i class="fas fa-info-circle"></i> No variant combinations to generate. Please configure property values in the variant collection.</p>';
|
|
return;
|
|
}
|
|
|
|
const variantRows = variantsToDisplay.map((combo, index) => this.createVariantRow(combo, index, propertyDefinitions)).join('');
|
|
|
|
const alertMessage = isAutoGenerated
|
|
? `<strong>Auto-Generated Variants:</strong> ${variantsToDisplay.length} variant combination(s) created. Fill in optional details for each variant.`
|
|
: `<strong>Existing Variants:</strong> ${variantsToDisplay.length} variant(s) loaded. You can modify or add new variants below.`;
|
|
|
|
this.dynamicFieldsContainer.innerHTML = `
|
|
<div class="alert alert-info mb-3">
|
|
<i class="fas fa-lightbulb"></i> ${alertMessage}
|
|
</div>
|
|
<div id="variant-rows-container">
|
|
${variantRows}
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-outline-primary mt-2" id="add-variant-row">
|
|
<i class="fas fa-plus"></i> Add Custom Variant
|
|
</button>
|
|
`;
|
|
|
|
document.getElementById('add-variant-row')?.addEventListener('click', () => {
|
|
this.addCustomVariantRow(propertyDefinitions);
|
|
});
|
|
|
|
this.attachVariantRowEventHandlers();
|
|
}
|
|
|
|
attachVariantRowEventHandlers() {
|
|
document.querySelectorAll('.remove-variant-row').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const row = e.target.closest('.variant-row');
|
|
if (row) {
|
|
row.remove();
|
|
this.updateStockCalculation();
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.toggle-variant-details').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const rowIndex = btn.dataset.row;
|
|
const detailsSection = document.querySelector(`.variant-details-section[data-row="${rowIndex}"]`);
|
|
const icon = btn.querySelector('i');
|
|
|
|
if (detailsSection) {
|
|
if (detailsSection.style.display === 'none') {
|
|
detailsSection.style.display = 'block';
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-up');
|
|
} else {
|
|
detailsSection.style.display = 'none';
|
|
icon.classList.remove('fa-chevron-up');
|
|
icon.classList.add('fa-chevron-down');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.variant-stock').forEach(input => {
|
|
input.addEventListener('input', () => {
|
|
this.updateStockCalculation();
|
|
});
|
|
});
|
|
|
|
this.updateStockCalculation();
|
|
}
|
|
|
|
updateStockCalculation() {
|
|
if (!this.productStockInput) return;
|
|
|
|
const variantStockInputs = document.querySelectorAll('.variant-stock');
|
|
let totalVariantStock = 0;
|
|
let hasVariantStock = false;
|
|
|
|
variantStockInputs.forEach(input => {
|
|
const value = input.value.trim();
|
|
if (value && !isNaN(value)) {
|
|
totalVariantStock += parseInt(value);
|
|
hasVariantStock = true;
|
|
}
|
|
});
|
|
|
|
const stockLabel = this.productStockInput.closest('.mb-3')?.querySelector('label');
|
|
let warningIcon = document.getElementById('stock-variant-warning');
|
|
|
|
if (hasVariantStock) {
|
|
this.productStockInput.disabled = true;
|
|
this.productStockInput.value = totalVariantStock;
|
|
this.productStockInput.classList.add('bg-light');
|
|
|
|
if (!warningIcon && stockLabel) {
|
|
warningIcon = document.createElement('i');
|
|
warningIcon.id = 'stock-variant-warning';
|
|
warningIcon.className = 'fas fa-calculator text-info ms-2';
|
|
warningIcon.style.cursor = 'pointer';
|
|
warningIcon.setAttribute('data-bs-toggle', 'tooltip');
|
|
warningIcon.setAttribute('data-bs-placement', 'top');
|
|
warningIcon.setAttribute('title', `Calculated from variants: ${totalVariantStock} units total. Modify variant stock quantities to change total.`);
|
|
stockLabel.appendChild(warningIcon);
|
|
|
|
if (typeof bootstrap !== 'undefined') {
|
|
new bootstrap.Tooltip(warningIcon);
|
|
}
|
|
} else if (warningIcon) {
|
|
warningIcon.setAttribute('title', `Calculated from variants: ${totalVariantStock} units total. Modify variant stock quantities to change total.`);
|
|
if (typeof bootstrap !== 'undefined') {
|
|
const tooltip = bootstrap.Tooltip.getInstance(warningIcon);
|
|
if (tooltip) {
|
|
tooltip.dispose();
|
|
new bootstrap.Tooltip(warningIcon);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
this.productStockInput.disabled = false;
|
|
this.productStockInput.classList.remove('bg-light');
|
|
|
|
if (warningIcon) {
|
|
const tooltip = bootstrap?.Tooltip?.getInstance(warningIcon);
|
|
if (tooltip) {
|
|
tooltip.dispose();
|
|
}
|
|
warningIcon.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
generateCombinations(propertyDefinitions) {
|
|
const properties = Object.keys(propertyDefinitions);
|
|
if (properties.length === 0) return [];
|
|
|
|
const propertyValues = properties.map(prop => {
|
|
const values = propertyDefinitions[prop];
|
|
return {
|
|
name: prop,
|
|
values: Array.isArray(values) && values.length > 0 ? values : [null]
|
|
};
|
|
});
|
|
|
|
const combinations = [];
|
|
const generate = (index, current) => {
|
|
if (index === propertyValues.length) {
|
|
combinations.push({...current});
|
|
return;
|
|
}
|
|
|
|
const prop = propertyValues[index];
|
|
for (const value of prop.values) {
|
|
current[prop.name] = value;
|
|
generate(index + 1, current);
|
|
}
|
|
};
|
|
|
|
generate(0, {});
|
|
return combinations;
|
|
}
|
|
|
|
createVariantRow(variantData, index, propertyDefinitions) {
|
|
const propertyFields = Object.entries(propertyDefinitions).map(([propName, propValues]) => {
|
|
const currentValue = variantData[propName];
|
|
let fieldHtml;
|
|
|
|
if (Array.isArray(propValues) && propValues.length > 0) {
|
|
const options = propValues.map(val =>
|
|
`<option value="${val}" ${val === currentValue ? 'selected' : ''}>${val}</option>`
|
|
).join('');
|
|
fieldHtml = `
|
|
<div class="col-md-3 mb-2">
|
|
<label class="form-label small">${propName}</label>
|
|
<select class="form-select form-select-sm variant-property" data-property="${propName}" data-row="${index}">
|
|
${options}
|
|
</select>
|
|
</div>
|
|
`;
|
|
} else {
|
|
fieldHtml = `
|
|
<div class="col-md-3 mb-2">
|
|
<label class="form-label small">${propName}</label>
|
|
<input type="text" class="form-control form-control-sm variant-property"
|
|
data-property="${propName}" data-row="${index}"
|
|
value="${currentValue || ''}" placeholder="Enter ${propName}" />
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return fieldHtml;
|
|
}).join('');
|
|
|
|
const variantLabel = Object.entries(variantData)
|
|
.filter(([k, v]) => v !== null && k !== 'Label' && k !== 'StockQty' && k !== 'Weight' && k !== 'WeightUnit')
|
|
.map(([k, v]) => v)
|
|
.join(' / ');
|
|
|
|
const existingLabel = variantData.Label || '';
|
|
const existingStockQty = variantData.StockQty || '';
|
|
const existingWeight = variantData.Weight || '';
|
|
const existingWeightUnit = variantData.WeightUnit !== undefined ? variantData.WeightUnit : (this.productWeightUnitSelect ? this.productWeightUnitSelect.value : '0');
|
|
|
|
const weightUnitOptions = `
|
|
<option value="0" ${existingWeightUnit == '0' ? 'selected' : ''}>Unit</option>
|
|
<option value="1" ${existingWeightUnit == '1' ? 'selected' : ''}>Micrograms</option>
|
|
<option value="2" ${existingWeightUnit == '2' ? 'selected' : ''}>Grams</option>
|
|
<option value="3" ${existingWeightUnit == '3' ? 'selected' : ''}>Ounces</option>
|
|
<option value="4" ${existingWeightUnit == '4' ? 'selected' : ''}>Pounds</option>
|
|
<option value="5" ${existingWeightUnit == '5' ? 'selected' : ''}>Millilitres</option>
|
|
<option value="6" ${existingWeightUnit == '6' ? 'selected' : ''}>Litres</option>
|
|
`;
|
|
|
|
return `
|
|
<div class="card mb-2 variant-row" data-row="${index}">
|
|
<div class="card-body p-3">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-1">
|
|
<strong class="text-muted">#${index + 1}</strong>
|
|
</div>
|
|
<div class="col-md-10">
|
|
<div class="row">
|
|
${propertyFields}
|
|
<div class="col-md-6 mb-2">
|
|
<label class="form-label small">Label/Description <small class="text-muted">(optional)</small></label>
|
|
<input type="text" class="form-control form-control-sm variant-label"
|
|
data-row="${index}" value="${existingLabel}" placeholder="e.g., ${variantLabel}" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
<button type="button" class="btn btn-sm btn-outline-secondary toggle-variant-details" data-row="${index}">
|
|
<i class="fas fa-chevron-down"></i> Stock & Weight Details
|
|
</button>
|
|
</div>
|
|
|
|
<div class="variant-details-section mt-2" data-row="${index}" style="display: none;">
|
|
<div class="row">
|
|
<div class="col-md-4 mb-2">
|
|
<label class="form-label small">Stock Quantity <small class="text-muted">(optional)</small></label>
|
|
<input type="number" min="0" class="form-control form-control-sm variant-stock"
|
|
data-row="${index}" value="${existingStockQty}" placeholder="Override stock qty" />
|
|
<small class="form-text text-muted">Leave blank to use product stock</small>
|
|
</div>
|
|
<div class="col-md-4 mb-2">
|
|
<label class="form-label small">Weight Override <small class="text-muted">(optional)</small></label>
|
|
<input type="number" step="0.01" min="0" class="form-control form-control-sm variant-weight"
|
|
data-row="${index}" value="${existingWeight}" placeholder="Override weight" />
|
|
<small class="form-text text-muted">Leave blank to use product weight</small>
|
|
</div>
|
|
<div class="col-md-4 mb-2">
|
|
<label class="form-label small">Weight Unit</label>
|
|
<select class="form-select form-select-sm variant-weight-unit" data-row="${index}">
|
|
${weightUnitOptions}
|
|
</select>
|
|
<small class="form-text text-muted">Defaults to product unit</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-1 text-end">
|
|
<button type="button" class="btn btn-sm btn-outline-danger remove-variant-row" title="Remove this variant">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
addCustomVariantRow(propertyDefinitions) {
|
|
const container = document.getElementById('variant-rows-container');
|
|
if (!container) return;
|
|
|
|
const existingRows = container.querySelectorAll('.variant-row').length;
|
|
const emptyVariant = {};
|
|
Object.keys(propertyDefinitions).forEach(prop => {
|
|
emptyVariant[prop] = null;
|
|
});
|
|
|
|
const newRowHtml = this.createVariantRow(emptyVariant, existingRows, propertyDefinitions);
|
|
container.insertAdjacentHTML('beforeend', newRowHtml);
|
|
|
|
this.attachVariantRowEventHandlers();
|
|
}
|
|
|
|
populateDynamicFields(variantData) {
|
|
if (!variantData || typeof variantData !== 'object') {
|
|
return;
|
|
}
|
|
|
|
Object.keys(variantData).forEach(key => {
|
|
const input = document.getElementById(`variant-${key}`);
|
|
if (input) {
|
|
input.value = variantData[key];
|
|
}
|
|
});
|
|
}
|
|
|
|
clearDynamicFields() {
|
|
this.dynamicFieldsContainer.innerHTML = '';
|
|
}
|
|
|
|
serializeVariantsToJson() {
|
|
if (!this.variantCollectionSelect.value) {
|
|
return;
|
|
}
|
|
|
|
const variantRows = document.querySelectorAll('.variant-row');
|
|
if (variantRows.length === 0) {
|
|
this.variantsJsonTextarea.value = '';
|
|
return;
|
|
}
|
|
|
|
const allVariants = [];
|
|
|
|
variantRows.forEach((row, rowIndex) => {
|
|
const variantData = {};
|
|
let hasValues = false;
|
|
|
|
const propertyInputs = row.querySelectorAll('.variant-property');
|
|
propertyInputs.forEach(input => {
|
|
const property = input.dataset.property;
|
|
const value = input.value.trim();
|
|
|
|
if (value) {
|
|
variantData[property] = value;
|
|
hasValues = true;
|
|
}
|
|
});
|
|
|
|
const labelInput = row.querySelector('.variant-label');
|
|
if (labelInput && labelInput.value.trim()) {
|
|
variantData['Label'] = labelInput.value.trim();
|
|
hasValues = true;
|
|
}
|
|
|
|
const stockInput = row.querySelector('.variant-stock');
|
|
if (stockInput && stockInput.value.trim()) {
|
|
variantData['StockQty'] = parseInt(stockInput.value.trim());
|
|
hasValues = true;
|
|
}
|
|
|
|
const weightInput = row.querySelector('.variant-weight');
|
|
if (weightInput && weightInput.value.trim()) {
|
|
variantData['Weight'] = parseFloat(weightInput.value.trim());
|
|
hasValues = true;
|
|
|
|
const weightUnitSelect = row.querySelector('.variant-weight-unit');
|
|
if (weightUnitSelect) {
|
|
variantData['WeightUnit'] = parseInt(weightUnitSelect.value);
|
|
}
|
|
}
|
|
|
|
if (hasValues) {
|
|
allVariants.push(variantData);
|
|
}
|
|
});
|
|
|
|
this.variantsJsonTextarea.value = allVariants.length > 0 ? JSON.stringify(allVariants) : '';
|
|
console.log('Serialized variants:', this.variantsJsonTextarea.value);
|
|
}
|
|
|
|
toggleAdvancedMode() {
|
|
if (this.advancedSection.style.display === 'none') {
|
|
this.showAdvancedMode();
|
|
} else {
|
|
this.hideAdvancedMode();
|
|
}
|
|
}
|
|
|
|
showAdvancedMode() {
|
|
if (this.advancedSection) {
|
|
this.advancedSection.style.display = 'block';
|
|
if (this.advancedToggle) {
|
|
this.advancedToggle.innerHTML = '<i class="fas fa-eye-slash"></i> Hide Advanced JSON Editor';
|
|
}
|
|
}
|
|
}
|
|
|
|
hideAdvancedMode() {
|
|
if (this.advancedSection) {
|
|
this.advancedSection.style.display = 'none';
|
|
if (this.advancedToggle) {
|
|
this.advancedToggle.innerHTML = '<i class="fas fa-code"></i> Show Advanced JSON Editor';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize on DOM ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (document.getElementById('VariantCollectionId')) {
|
|
window.productVariantsManager = new ProductVariantsManager();
|
|
console.log('ProductVariantsManager initialized');
|
|
}
|
|
}); |