Add quantity field (#74)

* Added Quantity Field

 Completed Changes
1. HTML Forms
Added quantity input fields to both asset and sub-asset forms
Fields default to value="1" with min="1" validation
Positioned after price fields for logical flow
2. Modal Manager (Frontend)
Updated form data collection to capture quantity values
Added quantity to form population for editing existing assets
Ensures quantity defaults to 1 if not provided
3. Asset Details Display
Added quantity field to asset info display in assetRenderer.js
Shows "Quantity: X" in the asset details section
Includes fallback to 1 for backwards compatibility
4. Server-side Backwards Compatibility
Asset Creation: Ensures quantity defaults to 1 if not provided
Asset Updates: Preserves existing quantity or defaults to 1
Sub-asset Creation: Ensures quantity defaults to 1 if not provided
Sub-asset Updates: Preserves existing quantity or defaults to 1
Data Loading: All assets and sub-assets get quantity field when loaded from API
5. Import Functionality
Added quantity column mapping option in import modal
Updated server-side import logic to handle quantity with default of 1
Updated template CSV download to include quantity example
Added auto-mapping for "quantity" and "qty" column headers
🔒 Backwards Compatibility
The implementation ensures that:
Existing assets without quantity will automatically show quantity: 1
All API endpoints handle missing quantity gracefully
Import functionality works with or without quantity columns
No data migration is required - compatibility is handled at runtime

* Add "Total Value" to asset details

 Total Value Field Features
Conditional Display
Only shows when quantity > 1
Only shows when there's a valid price (either price or purchasePrice)
Calculation
Calculates: price × quantity = total value
Uses the same currency formatting as other price fields
Handles both asset.price and asset.purchasePrice (for backwards compatibility)

* Updated Dashboard Value Calculation logic

Updated Calculation Logic to account for quantity
Assets:
price × quantity for each asset
Defaults to quantity = 1 for backwards compatibility
Sub-Assets:
purchasePrice × quantity for each sub-asset
Defaults to quantity = 1 for backwards compatibility
This commit is contained in:
abite 2025-06-12 04:51:52 -05:00 committed by GitHub
parent 52f3be257f
commit 08b8d771a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 85 additions and 9 deletions

View File

@ -170,6 +170,10 @@
<label for="assetPrice">Price</label>
<input type="number" id="assetPrice" name="price" step="0.01" min="0">
</div>
<div class="form-group">
<label for="assetQuantity">Quantity</label>
<input type="number" id="assetQuantity" name="quantity" min="1" value="1">
</div>
<div class="form-group">
<label for="assetWarrantyScope">Warranty Scope</label>
<input type="text" id="assetWarrantyScope" name="warrantyScope">
@ -359,6 +363,11 @@
<input type="number" id="subAssetPurchasePrice" name="purchasePrice" step="0.01">
</div>
<div class="form-group">
<label for="subAssetQuantity">Quantity</label>
<input type="number" id="subAssetQuantity" name="quantity" min="1" value="1">
</div>
<div class="form-group">
<label for="subAssetLink">Link</label>
<input type="url" id="subAssetLink" name="link">
@ -536,6 +545,10 @@
<label>Purchase Price:</label>
<select id="purchasePriceColumn" class="column-select"></select>
</div>
<div class="mapping-row">
<label>Quantity:</label>
<select id="quantityColumn" class="column-select"></select>
</div>
<div class="mapping-row">
<label>Notes:</label>
<select id="notesColumn" class="column-select"></select>

View File

@ -147,8 +147,16 @@ export class DashboardManager {
const totalComponents = totalSubAssets;
// Calculate total value including sub-assets
const totalAssetsValue = assets.reduce((sum, a) => sum + (parseFloat(a.price) || 0), 0);
const totalSubAssetsValue = subAssets.reduce((sum, sa) => sum + (parseFloat(sa.purchasePrice) || 0), 0);
const totalAssetsValue = assets.reduce((sum, a) => {
const price = parseFloat(a.price) || 0;
const quantity = a.quantity || 1;
return sum + (price * quantity);
}, 0);
const totalSubAssetsValue = subAssets.reduce((sum, sa) => {
const price = parseFloat(sa.purchasePrice) || 0;
const quantity = sa.quantity || 1;
return sum + (price * quantity);
}, 0);
const totalValue = totalAssetsValue + totalSubAssetsValue;

View File

@ -146,7 +146,8 @@ export class ImportManager {
const warrantyExpirationColumn = document.getElementById('warrantyExpirationColumn');
const lifetimeColumn = document.getElementById('lifetimeColumn');
const tagsColumn = document.getElementById('tagsColumn');
[urlColumn, warrantyColumn, warrantyExpirationColumn, lifetimeColumn, tagsColumn].forEach(select => {
const quantityColumn = document.getElementById('quantityColumn');
[urlColumn, warrantyColumn, warrantyExpirationColumn, lifetimeColumn, tagsColumn, quantityColumn].forEach(select => {
if (!select) return;
select.innerHTML = '<option value="">Select Column</option>';
headers.forEach((header, index) => {
@ -194,7 +195,8 @@ export class ImportManager {
lifetime: document.getElementById('lifetimeColumn').value,
secondaryWarranty: document.getElementById('secondaryWarrantyColumn') ? document.getElementById('secondaryWarrantyColumn').value : '',
secondaryWarrantyExpiration: document.getElementById('secondaryWarrantyExpirationColumn') ? document.getElementById('secondaryWarrantyExpirationColumn').value : '',
tags: document.getElementById('tagsColumn') ? document.getElementById('tagsColumn').value : ''
tags: document.getElementById('tagsColumn') ? document.getElementById('tagsColumn').value : '',
quantity: document.getElementById('quantityColumn') ? document.getElementById('quantityColumn').value : ''
};
if (!mappings.name) {
globalThis.toaster.show('Please map the Name column', 'error');
@ -286,7 +288,8 @@ export class ImportManager {
lifetimeColumn: ["lifetime", "lifetime warranty", "is lifetime", "islifetime", "permanent"],
secondaryWarrantyColumn: ["secondary warranty", "secondary warranty scope", "warranty 2", "warranty2", "warranty scope 2"],
secondaryWarrantyExpirationColumn: ["secondary warranty expiration", "secondary warranty expiry", "secondary warranty end", "secondary warranty end date", "warranty 2 expiration", "warranty2 expiration", "warranty expiration 2", "warranty expiry 2"],
tagsColumn: ["tags", "tag", "labels", "categories"]
tagsColumn: ["tags", "tag", "labels", "categories"],
quantityColumn: ["quantity", "qty"]
};
function normalize(str) {
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
@ -342,7 +345,8 @@ export class ImportManager {
'lifetimeColumn',
'secondaryWarrantyColumn',
'secondaryWarrantyExpirationColumn',
'tagsColumn'
'tagsColumn',
'quantityColumn'
];
columnIds.forEach(id => {
const select = document.getElementById(id);
@ -373,7 +377,8 @@ export class ImportManager {
'Lifetime',
'Secondary Warranty',
'Secondary Warranty Expiration',
'Tags'
'Tags',
'Quantity'
];
// Generate test data row
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
@ -383,6 +388,7 @@ export class ImportManager {
if (lower === 'url') return 'https://example.com';
if (lower === 'tags') return '"tag1,tag2,tag3"'; // CSV string for tags
if (lower === 'purchase price') return '123.45';
if (lower === 'quantity') return '1'; // Default quantity
if (lower === 'lifetime') return 'false'; // Boolean value for lifetime warranty
return `Test ${h}`;
});

View File

@ -374,6 +374,7 @@ export class ModalManager {
'assetSerial': asset.serialNumber || '',
'assetPurchaseDate': asset.purchaseDate || '',
'assetPrice': asset.price || '',
'assetQuantity': asset.quantity || 1,
'assetWarrantyScope': asset.warranty?.scope || '',
'assetWarrantyLifetime': asset.warranty?.isLifetime || false,
'assetWarrantyExpiration': asset.warranty?.expirationDate ? new Date(formatDate(asset.warranty.expirationDate)).toISOString().split('T')[0] : '',
@ -408,6 +409,7 @@ export class ModalManager {
'subAssetSerial': subAsset.serialNumber || '',
'subAssetPurchaseDate': subAsset.purchaseDate || '',
'subAssetPurchasePrice': subAsset.purchasePrice || '',
'subAssetQuantity': subAsset.quantity || 1,
'subAssetLink': subAsset.link || '',
'subAssetNotes': subAsset.notes || '',
'subAssetWarrantyScope': subAsset.warranty?.scope || '',
@ -776,6 +778,7 @@ export class ModalManager {
serialNumber: document.getElementById('assetSerial')?.value || '',
purchaseDate: document.getElementById('assetPurchaseDate')?.value || '',
price: parseFloat(document.getElementById('assetPrice')?.value) || null,
quantity: parseInt(document.getElementById('assetQuantity')?.value) || 1,
warranty: {
scope: document.getElementById('assetWarrantyScope')?.value || '',
expirationDate: document.getElementById('assetWarrantyLifetime')?.checked ? null : (document.getElementById('assetWarrantyExpiration')?.value || ''),
@ -843,6 +846,7 @@ export class ModalManager {
serialNumber: document.getElementById('subAssetSerial')?.value || '',
purchaseDate: document.getElementById('subAssetPurchaseDate')?.value || '',
purchasePrice: parseFloat(document.getElementById('subAssetPurchasePrice')?.value) || null,
quantity: parseInt(document.getElementById('subAssetQuantity')?.value) || 1,
parentId: document.getElementById('parentAssetId')?.value || '',
parentSubId: document.getElementById('parentSubAssetId')?.value || '',
link: document.getElementById('subAssetLink')?.value || '',

View File

@ -622,13 +622,27 @@ if (!fs.existsSync(subAssetsFilePath)) {
// Get all assets
app.get('/api/assets', (req, res) => {
const assets = readJsonFile(assetsFilePath);
res.json(assets);
// Ensure backwards compatibility for quantity field
const assetsWithQuantity = assets.map(asset => ({
...asset,
quantity: asset.quantity || 1
}));
res.json(assetsWithQuantity);
});
// Get all sub-assets
app.get('/api/subassets', (req, res) => {
const subAssets = readJsonFile(subAssetsFilePath);
res.json(subAssets);
// Ensure backwards compatibility for quantity field
const subAssetsWithQuantity = subAssets.map(subAsset => ({
...subAsset,
quantity: subAsset.quantity || 1
}));
res.json(subAssetsWithQuantity);
});
// Create a new asset
@ -639,6 +653,11 @@ app.post('/api/asset', async (req, res) => {
// Ensure maintenanceEvents is always present (even if empty)
newAsset.maintenanceEvents = newAsset.maintenanceEvents || [];
// Ensure quantity is present for backwards compatibility
if (typeof newAsset.quantity === 'undefined' || newAsset.quantity === null) {
newAsset.quantity = 1;
}
// Ensure required fields
if (!newAsset.name) {
return res.status(400).json({ error: 'Asset name is required' });
@ -713,6 +732,11 @@ app.put('/api/assets/:id', async (req, res) => {
const existingAsset = assets[assetIndex];
// Ensure quantity is present for backwards compatibility
if (typeof updatedAssetData.quantity === 'undefined' || updatedAssetData.quantity === null) {
updatedAssetData.quantity = existingAsset.quantity || 1;
}
if (updatedAssetData.filesToDelete && updatedAssetData.filesToDelete.length > 0) {
await deleteAssetFiles(updatedAssetData.filesToDelete);
}
@ -845,6 +869,11 @@ app.post('/api/subasset', async (req, res) => {
// Ensure maintenanceEvents is always present (even if empty)
newSubAsset.maintenanceEvents = newSubAsset.maintenanceEvents || [];
// Ensure quantity is present for backwards compatibility
if (typeof newSubAsset.quantity === 'undefined' || newSubAsset.quantity === null) {
newSubAsset.quantity = 1;
}
// Ensure required fields
if (!newSubAsset.name || !newSubAsset.parentId) {
return res.status(400).json({ error: 'Sub-asset name and parent ID are required' });
@ -920,6 +949,11 @@ app.put('/api/subassets/:id', async (req, res) => {
const existingSubAsset = subAssets[subAssetIndex];
// Ensure quantity is present for backwards compatibility
if (typeof updatedSubAssetData.quantity === 'undefined' || updatedSubAssetData.quantity === null) {
updatedSubAssetData.quantity = existingSubAsset.quantity || 1;
}
if (updatedSubAssetData.filesToDelete && updatedSubAssetData.filesToDelete.length > 0) {
await deleteAssetFiles(updatedSubAssetData.filesToDelete);
}
@ -1285,6 +1319,7 @@ app.post('/api/import-assets', upload.single('file'), (req, res) => {
serialNumber: get('serial'),
purchaseDate: parseExcelDate(get('purchaseDate')),
price: get('purchasePrice'),
quantity: parseInt(get('quantity')) || 1,
description: get('notes'),
link: get('url'),
warranty: {

View File

@ -184,6 +184,16 @@ function generateAssetInfoHTML(asset) {
<div class="info-label">Price</div>
<div>${formatCurrency(asset.price || asset.purchasePrice)}</div>
</div>
<div class="info-item">
<div class="info-label">Quantity</div>
<div>${asset.quantity || 1}</div>
</div>
${(asset.quantity > 1 && (asset.price || asset.purchasePrice)) ? `
<div class="info-item">
<div class="info-label">Total Value</div>
<div>${formatCurrency((asset.price || asset.purchasePrice) * asset.quantity)}</div>
</div>
` : ''}
${asset.warranty?.expirationDate || asset.warranty?.isLifetime ? `
<div class="info-item">
<div class="info-label">Warranty</div>