mirror of
https://github.com/DumbWareio/DumbAssets.git
synced 2026-02-20 00:24:29 +08:00
* feat: implement asset and component duplication feature (#79) Add comprehensive duplication functionality allowing users to create multiple copies of existing assets and components with sequential naming and sanitized data. - **Duplicate Buttons**: Added duplicate buttons between Save/Cancel in asset and sub-asset modals - **Smart Visibility**: Buttons only appear in edit mode (not when creating new items) - **Sequential Naming**: Duplicates automatically named with incremental numbers (e.g., "Asset Name (1)", "Asset Name (2)") - **Input Validation**: Users can create 1-100 duplicates with proper validation - **Data Sanitization**: Excludes serial numbers, warranty info, files, and maintenance events from duplicates - **Bulk Operations**: Efficient server-side bulk creation endpoints - **public/index.html**: - Added duplicate buttons to asset and sub-asset modal form-actions - Added duplicate confirmation modal with count input - Updated help text to explain sequential naming and data exclusions - **public/styles.css**: - Added styling for duplicate buttons with hover states - Added duplicate modal and input styling - Added responsive design support - **public/managers/modalManager.js**: - Extended with complete duplication functionality - Added duplicate modal management methods - Implemented sequential naming logic - Added data sanitization for duplicates - **public/script.js**: - Added duplicate modal to escape key handler - Added click-off-to-close functionality - Exposed refreshAllData for ModalManager access - **server.js**: - Added `/api/assets/bulk` endpoint for bulk asset creation - Added `/api/subassets/bulk` endpoint for bulk sub-asset creation - Added validation for bulk operations (max 100 items) - Added notification support for bulk operations - Restored accidentally removed endpoints (settings, uploads, etc.) - **Data Processing**: Strips sensitive data while preserving core asset information - **Error Handling**: Comprehensive validation and user feedback - **Performance**: Bulk server operations for efficiency - **UX**: Loading states, success messages, keyboard shortcuts Perfect for scenarios like: - Creating multiple identical hard drives with different serial numbers - Duplicating network equipment across locations - Bulk adding similar components to inventory - Setting up template assets for mass deployment Resolves #79 * reorg styles.css * Add properties grid to duplicate asset/subasset modal * Add components and sub components duplication option to duplicate grid * update file previews for subcomponents to support multiple file paths * minor updates to duplicate modal styling * update duplicate sub asset button icon * Add duplicate button to asset / subasset actions * apply svg styles to duplicate asset-actions buttons * Fix component's sub component duplication * Fix rendering proper asset / subasset after duplication * refactor duplication/clone assets code into it's own manager class as well as closing asset modals after performing duplication * fix asset navigation after duplicating an asset --------- Co-authored-by: gitmotion <43588713+gitmotion@users.noreply.github.com>
384 lines
16 KiB
JavaScript
384 lines
16 KiB
JavaScript
/**
|
|
* Duplication Manager
|
|
* Handles all duplication operations for assets and sub-assets
|
|
*/
|
|
|
|
export class DuplicationManager {
|
|
constructor({
|
|
// Utility functions
|
|
setButtonLoading,
|
|
generateId,
|
|
|
|
// Navigation functions
|
|
renderAssetDetails,
|
|
closeAssetModal,
|
|
closeSubAssetModal,
|
|
|
|
// Data functions
|
|
refreshData,
|
|
getAssets,
|
|
getSubAssets
|
|
}) {
|
|
// Store dependencies
|
|
this.setButtonLoading = setButtonLoading;
|
|
this.generateId = generateId;
|
|
this.renderAssetDetails = renderAssetDetails;
|
|
this.closeAssetModal = closeAssetModal;
|
|
this.closeSubAssetModal = closeSubAssetModal;
|
|
this.refreshData = refreshData;
|
|
this.getAssets = getAssets;
|
|
this.getSubAssets = getSubAssets;
|
|
|
|
// DOM elements
|
|
this.duplicateModal = document.getElementById('duplicateModal');
|
|
this.duplicateModalTitle = document.getElementById('duplicateModalTitle');
|
|
this.duplicateModalDescription = document.getElementById('duplicateModalDescription');
|
|
this.duplicateCountInput = document.getElementById('duplicateCount');
|
|
this.duplicatePropertiesSection = document.getElementById('duplicatePropertiesSection');
|
|
this.duplicatePropertiesGrid = document.getElementById('duplicatePropertiesGrid');
|
|
this.confirmDuplicateBtn = document.getElementById('confirmDuplicateBtn');
|
|
this.cancelDuplicateBtn = document.getElementById('cancelDuplicateBtn');
|
|
|
|
// State
|
|
this.duplicateType = null;
|
|
this.duplicateSource = null;
|
|
|
|
// Define properties that can be duplicated for assets and sub-assets
|
|
this.duplicatableProperties = {
|
|
asset: {
|
|
'manufacturer': { label: 'Manufacturer', default: true },
|
|
'modelNumber': { label: 'Model Number', default: true },
|
|
'serialNumber': { label: 'Serial Number', default: true },
|
|
'description': { label: 'Description', default: true },
|
|
'purchaseDate': { label: 'Purchase Date', default: true },
|
|
'price': { label: 'Purchase Price', default: true },
|
|
'quantity': { label: 'Quantity', default: true },
|
|
'link': { label: 'Product Link', default: true },
|
|
'tags': { label: 'Tags', default: true },
|
|
'warranty': { label: 'Warranty Info', default: true },
|
|
'secondaryWarranty': { label: 'Secondary Warranty', default: true },
|
|
'maintenanceEvents': { label: 'Maintenance Events', default: true },
|
|
'photoPath': { label: 'Photos', default: true },
|
|
'receiptPath': { label: 'Receipts', default: true },
|
|
'manualPath': { label: 'Manuals', default: true },
|
|
'subAssets': { label: 'Copy Components', default: true }
|
|
},
|
|
subAsset: {
|
|
'manufacturer': { label: 'Manufacturer', default: true },
|
|
'modelNumber': { label: 'Model Number', default: true },
|
|
'serialNumber': { label: 'Serial Number', default: true },
|
|
'notes': { label: 'Notes', default: true },
|
|
'purchaseDate': { label: 'Purchase Date', default: true },
|
|
'purchasePrice': { label: 'Purchase Price', default: true },
|
|
'quantity': { label: 'Quantity', default: true },
|
|
'link': { label: 'Product Link', default: true },
|
|
'tags': { label: 'Tags', default: true },
|
|
'warranty': { label: 'Warranty Info', default: true },
|
|
'maintenanceEvents': { label: 'Maintenance Events', default: true },
|
|
'photoPath': { label: 'Photos', default: true },
|
|
'receiptPath': { label: 'Receipts', default: true },
|
|
'manualPath': { label: 'Manuals', default: true },
|
|
'subAssets': { label: 'Copy Sub-Components', default: true }
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Open the duplication modal
|
|
* @param {string} type - 'asset' or 'subAsset'
|
|
* @param {string} itemId - ID of the item to duplicate
|
|
*/
|
|
openDuplicateModal(type, itemId = null) {
|
|
if (!this.duplicateModal) return;
|
|
|
|
this.duplicateType = type;
|
|
|
|
// Find the source asset/subAsset by ID
|
|
if (itemId) {
|
|
if (type === 'asset') {
|
|
const assets = this.getAssets();
|
|
this.duplicateSource = assets.find(a => a.id === itemId);
|
|
} else {
|
|
const subAssets = this.getSubAssets();
|
|
this.duplicateSource = subAssets.find(sa => sa.id === itemId);
|
|
}
|
|
}
|
|
|
|
if (!this.duplicateSource) {
|
|
globalThis.toaster.show(`Failed to find ${type} with ID: ${itemId}`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Update modal content
|
|
this.duplicateModalTitle.textContent = type === 'asset' ? 'Duplicate Asset' : 'Duplicate Component';
|
|
this.duplicateModalDescription.textContent = `How many duplicates of "${this.duplicateSource.name}" would you like to create?`;
|
|
|
|
// Reset input
|
|
this.duplicateCountInput.value = '1';
|
|
|
|
// Reset properties section to collapsed
|
|
if (this.duplicatePropertiesSection) {
|
|
this.duplicatePropertiesSection.setAttribute('data-collapsed', 'true');
|
|
}
|
|
|
|
// Generate property toggles
|
|
this.generatePropertyToggles(type);
|
|
|
|
// Initialize collapsible section
|
|
if (window.initCollapsibleSections) {
|
|
window.initCollapsibleSections();
|
|
}
|
|
|
|
// Set up event listeners
|
|
this.setupDuplicateModalButtons();
|
|
|
|
// Focus on count input
|
|
this.duplicateCountInput.focus();
|
|
|
|
// Show modal
|
|
this.duplicateModal.style.display = 'block';
|
|
}
|
|
|
|
/**
|
|
* Close the duplication modal
|
|
*/
|
|
closeDuplicateModal() {
|
|
if (!this.duplicateModal) return;
|
|
|
|
this.duplicateModal.style.display = 'none';
|
|
this.duplicateType = null;
|
|
this.duplicateSource = null;
|
|
|
|
// Remove event listeners
|
|
if (this.confirmDuplicateBtn) {
|
|
this.confirmDuplicateBtn.onclick = null;
|
|
}
|
|
if (this.cancelDuplicateBtn) {
|
|
this.cancelDuplicateBtn.onclick = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up event listeners for the duplicate modal buttons
|
|
*/
|
|
setupDuplicateModalButtons() {
|
|
// Set up confirm button
|
|
if (this.confirmDuplicateBtn) {
|
|
this.confirmDuplicateBtn.onclick = () => {
|
|
this.performDuplication();
|
|
};
|
|
}
|
|
|
|
// Set up cancel button
|
|
if (this.cancelDuplicateBtn) {
|
|
this.cancelDuplicateBtn.onclick = () => {
|
|
this.closeDuplicateModal();
|
|
};
|
|
}
|
|
|
|
// Set up close button
|
|
const closeBtn = this.duplicateModal.querySelector('.close-btn');
|
|
if (closeBtn) {
|
|
closeBtn.onclick = () => {
|
|
this.closeDuplicateModal();
|
|
};
|
|
}
|
|
|
|
// Set up Enter key handler
|
|
this.duplicateCountInput.onkeydown = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
this.performDuplication();
|
|
}
|
|
};
|
|
|
|
// Set up input event to update button text dynamically
|
|
this.duplicateCountInput.oninput = () => {
|
|
this.updateDuplicateButtonText();
|
|
};
|
|
|
|
// Initialize button text
|
|
this.updateDuplicateButtonText();
|
|
}
|
|
|
|
/**
|
|
* Update the duplicate button text based on count
|
|
*/
|
|
updateDuplicateButtonText() {
|
|
if (!this.confirmDuplicateBtn || !this.duplicateCountInput) return;
|
|
|
|
const count = parseInt(this.duplicateCountInput.value) || 1;
|
|
const duplicateIcon = `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
<path stroke="none" d="M0 0h24v24H0z" />
|
|
<path
|
|
d="M7 9.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z" />
|
|
<path d="M4.012 16.737a2 2 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1" />
|
|
<path d="M11 14h6" />
|
|
<path d="M14 11v6" />
|
|
</svg>`;
|
|
if (count === 1) {
|
|
this.confirmDuplicateBtn.innerHTML = `${duplicateIcon} Create Duplicate`;
|
|
} else {
|
|
this.confirmDuplicateBtn.innerHTML = `${duplicateIcon} Create Duplicates (${count})`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform the duplication operation
|
|
*/
|
|
async performDuplication() {
|
|
const count = parseInt(this.duplicateCountInput.value);
|
|
|
|
if (!count || count < 1 || count > 100) {
|
|
globalThis.toaster.show('Please enter a valid number between 1 and 100', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.setButtonLoading(this.confirmDuplicateBtn, true);
|
|
|
|
// Get selected properties
|
|
const selectedProperties = this.getSelectedProperties();
|
|
|
|
// Create the source object with only the original asset data (no modifications)
|
|
const sourceData = { ...this.duplicateSource };
|
|
|
|
// Send duplication request to server with source data and property selections
|
|
const apiBaseUrl = globalThis.getApiBaseUrl();
|
|
const endpoint = this.duplicateType === 'asset' ? '/api/assets/duplicate' : '/api/subassets/duplicate';
|
|
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
source: sourceData,
|
|
count: count,
|
|
selectedProperties: selectedProperties
|
|
}),
|
|
credentials: 'include'
|
|
});
|
|
|
|
const responseValidation = await globalThis.validateResponse(response);
|
|
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
|
|
|
|
// Parse the response to get the created items
|
|
const result = await response.json();
|
|
const createdItems = result.items || [];
|
|
|
|
// Store the duplicate type before closing modal (since closeDuplicateModal sets it to null)
|
|
const duplicatedType = this.duplicateType;
|
|
|
|
// Close the duplicate modal
|
|
this.closeDuplicateModal();
|
|
this.closeAssetModal();
|
|
this.closeSubAssetModal();
|
|
|
|
// Refresh data first
|
|
await this.refreshData();
|
|
|
|
// Navigate appropriately based on what was duplicated
|
|
if (createdItems.length > 0 && this.renderAssetDetails) {
|
|
const firstItem = createdItems[0];
|
|
|
|
if (duplicatedType === 'asset') {
|
|
// For assets, navigate to the first duplicated asset
|
|
console.log(`[DuplicationManager] Navigating to first duplicated asset: ${firstItem.id}`);
|
|
this.renderAssetDetails(firstItem.id, false);
|
|
} else {
|
|
// For sub-assets, we need to determine the navigation context
|
|
if (firstItem.parentSubId) {
|
|
// This is a sub-sub-asset (component of a component)
|
|
// Stay on the parent sub-asset to show the newly duplicated sub-component
|
|
console.log(`[DuplicationManager] Navigating to parent sub-asset: ${firstItem.parentSubId}`);
|
|
this.renderAssetDetails(firstItem.parentSubId, true);
|
|
} else {
|
|
// This is a first-level sub-asset (component of an asset)
|
|
// We need to check the current context to decide where to navigate
|
|
const sourceItem = this.duplicateSource;
|
|
|
|
if (sourceItem && sourceItem.parentSubId) {
|
|
// If the source was also a sub-sub-asset, stay on the parent sub-asset
|
|
console.log(`[DuplicationManager] Source was sub-sub-asset, staying on parent sub-asset: ${sourceItem.parentSubId}`);
|
|
this.renderAssetDetails(sourceItem.parentSubId, true);
|
|
} else {
|
|
// Source was a first-level sub-asset, show the parent asset with the new component
|
|
const parentAssetId = firstItem.parentId;
|
|
if (parentAssetId) {
|
|
console.log(`[DuplicationManager] Navigating to parent asset: ${parentAssetId}`);
|
|
this.renderAssetDetails(parentAssetId, false);
|
|
} else {
|
|
// Fallback: navigate to the sub-asset itself if no parent found
|
|
console.log(`[DuplicationManager] Fallback - navigating to sub-asset: ${firstItem.id}`);
|
|
this.renderAssetDetails(firstItem.id, true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show success message
|
|
globalThis.toaster.show(`Successfully created ${count} duplicate${count > 1 ? 's' : ''}!`);
|
|
|
|
} catch (error) {
|
|
globalThis.logError('Error creating duplicates:', error.message);
|
|
} finally {
|
|
this.setButtonLoading(this.confirmDuplicateBtn, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate property toggles for the duplicate modal
|
|
* @param {string} type - 'asset' or 'subAsset'
|
|
*/
|
|
generatePropertyToggles(type) {
|
|
if (!this.duplicatePropertiesGrid) return;
|
|
|
|
// Clear existing toggles
|
|
this.duplicatePropertiesGrid.innerHTML = '';
|
|
|
|
const properties = this.duplicatableProperties[type];
|
|
if (!properties) return;
|
|
|
|
// Create toggle for each property
|
|
Object.entries(properties).forEach(([key, config]) => {
|
|
const toggleRow = document.createElement('div');
|
|
toggleRow.className = 'toggle-row';
|
|
|
|
// Create toggle HTML structure matching settings modal
|
|
toggleRow.innerHTML = `
|
|
<span>${config.label}</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox"
|
|
id="duplicate-${key}"
|
|
name="duplicate-${key}"
|
|
${config.default ? 'checked' : ''}>
|
|
<span class="slider"></span>
|
|
</label>
|
|
`;
|
|
|
|
this.duplicatePropertiesGrid.appendChild(toggleRow);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the selected properties from the duplicate modal
|
|
* @returns {Object} Object with selected properties
|
|
*/
|
|
getSelectedProperties() {
|
|
const selected = {};
|
|
const properties = this.duplicatableProperties[this.duplicateType];
|
|
if (!properties) return selected;
|
|
|
|
Object.keys(properties).forEach(key => {
|
|
const checkbox = document.getElementById(`duplicate-${key}`);
|
|
if (checkbox) {
|
|
selected[key] = checkbox.checked;
|
|
}
|
|
});
|
|
|
|
return selected;
|
|
}
|
|
}
|