mirror of
https://github.com/DumbWareio/DumbAssets.git
synced 2026-01-22 03:43:52 +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>
1923 lines
85 KiB
JavaScript
1923 lines
85 KiB
JavaScript
/**
|
|
* DumbAssets - Asset Tracking Application
|
|
* Main JavaScript file handling application logic
|
|
*/
|
|
|
|
// Debug mode flag - set to true to enable debug logging
|
|
const DEBUG = window.appConfig?.debug || false;
|
|
|
|
// Global handlers for error logging, response validation, toaster, and getApiBaseUrl
|
|
import { GlobalHandlers } from './managers/globalHandlers.js';
|
|
new GlobalHandlers();
|
|
|
|
// Import file upload module
|
|
import { initializeFileUploads, handleFileUploads } from '/src/services/fileUpload/index.js';
|
|
import { formatFileSize } from '/src/services/fileUpload/utils.js';
|
|
// Import asset renderer module
|
|
import {
|
|
initRenderer,
|
|
updateState,
|
|
updateSelectedIds,
|
|
renderAssetDetails,
|
|
formatFilePath,
|
|
// Import list renderer functions
|
|
initListRenderer,
|
|
updateListState,
|
|
updateDashboardFilter as updateListDashboardFilter,
|
|
updateSort,
|
|
renderAssetList,
|
|
sortAssets,
|
|
// Import file preview renderer
|
|
setupFilePreview,
|
|
setupExistingFilePreview
|
|
} from '/src/services/render/index.js';
|
|
import { ChartManager } from '/managers/charts.js';
|
|
import { registerServiceWorker } from './helpers/serviceWorkerHelper.js';
|
|
import {
|
|
initCollapsibleSections,
|
|
expandSection,
|
|
collapseSection
|
|
} from './js/collapsible.js';
|
|
import { SettingsManager } from './managers/settings.js';
|
|
import { generateId, formatDate, formatCurrency } from './helpers/utils.js';
|
|
import { ImportManager } from './managers/import.js';
|
|
import { MaintenanceManager } from './managers/maintenanceManager.js';
|
|
import { ModalManager } from './managers/modalManager.js';
|
|
import { DashboardManager } from './managers/dashboardManager.js';
|
|
import { DuplicationManager } from './managers/duplicationManager.js';
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initialize variables for app state
|
|
let assets = [];
|
|
let subAssets = [];
|
|
let selectedAssetId = null;
|
|
let selectedSubAssetId = null;
|
|
let dashboardFilter = 'all';
|
|
let currentSort = { field: 'updatedAt', direction: 'desc' };
|
|
|
|
// Local function to update dashboard filter and keep modules in sync
|
|
function updateDashboardFilter(filter) {
|
|
dashboardFilter = filter || 'all';
|
|
updateListDashboardFilter(filter);
|
|
}
|
|
|
|
// DOM Elements
|
|
const siteTitleElem = document.getElementById('siteTitle');
|
|
const pageTitleElem = document.getElementById('pageTitle');
|
|
const assetModal = document.getElementById('assetModal');
|
|
const assetForm = document.getElementById('assetForm');
|
|
const subAssetModal = document.getElementById('subAssetModal');
|
|
const subAssetForm = document.getElementById('subAssetForm');
|
|
const assetList = document.getElementById('assetList');
|
|
const assetDetails = document.getElementById('assetDetails');
|
|
const subAssetContainer = document.getElementById('subAssetContainer');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const clearSearchBtn = document.getElementById('clearSearchBtn');
|
|
const clearFiltersBtn = document.getElementById('clearFiltersBtn');
|
|
const subAssetList = document.getElementById('subAssetList');
|
|
const addAssetBtn = document.getElementById('addAssetBtn');
|
|
const addSubAssetBtn = document.getElementById('addSubAssetBtn');
|
|
const sidebar = document.querySelector('.sidebar');
|
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
|
const sidebarOverlay = document.getElementById('sidebarOverlay');
|
|
const sidebarOpenBtn = document.getElementById('sidebarOpen');
|
|
const sidebarCloseBtn = document.getElementById('sidebarClose');
|
|
const mainContent = document.querySelector('.main-content');
|
|
const sortNameBtn = document.getElementById('sortNameBtn');
|
|
const sortWarrantyBtn = document.getElementById('sortWarrantyBtn');
|
|
const topSortBtn = document.getElementById('topSortBtn');
|
|
const homeBtn = document.getElementById('homeBtn');
|
|
|
|
// Import functionality
|
|
const importModal = document.getElementById('importModal');
|
|
const importBtn = document.getElementById('importAssetsBtn');
|
|
const importFile = document.getElementById('importFile');
|
|
const startImportBtn = document.getElementById('startImportBtn');
|
|
const columnSelects = document.querySelectorAll('.column-select');
|
|
|
|
// Settings UI
|
|
const settingsBtn = document.getElementById('settingsBtn');
|
|
const settingsModal = document.getElementById('settingsModal');
|
|
const notificationForm = document.getElementById('notificationForm');
|
|
const saveSettings = document.getElementById('saveSettings');
|
|
const cancelSettings = document.getElementById('cancelSettings');
|
|
const settingsClose = settingsModal ? settingsModal.querySelector('.close-btn') : null;
|
|
const testNotificationSettings = document.getElementById('testNotificationSettings');
|
|
const toggleCardTotalAssets = document.getElementById('toggleCardTotalAssets')
|
|
const toggleCardTotalComponents = document.getElementById('toggleCardTotalComponents')
|
|
const toggleCardTotalValue = document.getElementById('toggleCardTotalValue')
|
|
const toggleCardWarrantiesTotal = document.getElementById('toggleCardWarrantiesTotal')
|
|
const toggleCardWarrantiesWithin60 = document.getElementById('toggleCardWarrantiesWithin60')
|
|
const toggleCardWarrantiesWithin30 = document.getElementById('toggleCardWarrantiesWithin30')
|
|
const toggleCardWarrantiesExpired = document.getElementById('toggleCardWarrantiesExpired')
|
|
const toggleCardWarrantiesActive = document.getElementById('toggleCardWarrantiesActive')
|
|
|
|
let settingsManager;
|
|
let modalManager;
|
|
let dashboardManager;
|
|
let duplicationManager;
|
|
const chartManager = new ChartManager({ formatDate });
|
|
|
|
// Acts as constructor for the app
|
|
// will be called at the very end of the file
|
|
function initialize() {
|
|
// Display demo banner if in demo mode
|
|
if (window.appConfig?.demoMode) {
|
|
document.getElementById('demo-banner').style.display = 'block';
|
|
}
|
|
|
|
addWindowEventListenersAndProperties();
|
|
// initialize page title right away
|
|
setupPageTitle();
|
|
// Load initial data
|
|
loadAllData().then(() => {
|
|
// Initialize dashboard manager first
|
|
dashboardManager = new DashboardManager({
|
|
// DOM elements
|
|
assetDetails,
|
|
subAssetContainer,
|
|
searchInput,
|
|
clearFiltersBtn,
|
|
|
|
// Utility functions
|
|
formatDate,
|
|
formatCurrency,
|
|
|
|
// Managers
|
|
chartManager,
|
|
settingsManager,
|
|
|
|
// UI functions
|
|
updateDashboardFilter,
|
|
updateSort,
|
|
updateSelectedIds,
|
|
renderAssetDetails,
|
|
renderAssetList,
|
|
handleSidebarNav,
|
|
setButtonLoading,
|
|
|
|
// Global state getters
|
|
getAssets: () => assets,
|
|
getSubAssets: () => subAssets,
|
|
getDashboardFilter: () => dashboardFilter,
|
|
getCurrentSort: () => currentSort,
|
|
getSelectedAssetId: () => selectedAssetId
|
|
});
|
|
|
|
// Expose dashboardManager to global scope for chart access
|
|
window.dashboardManager = dashboardManager;
|
|
|
|
// After data is loaded, check for URL parameters
|
|
if (!handleUrlParameters()) {
|
|
// No URL parameters, show empty state as normal
|
|
dashboardManager.renderDashboard();
|
|
}
|
|
});
|
|
|
|
// Set up file upload functionality
|
|
initializeFileUploads();
|
|
|
|
// Initialize collapsible sections
|
|
initCollapsibleSections();
|
|
|
|
// Initialize the asset renderer module
|
|
initRenderer({
|
|
// Utility functions
|
|
formatDate,
|
|
formatCurrency,
|
|
|
|
// Module functions
|
|
openAssetModal: (asset) => modalManager.openAssetModal(asset),
|
|
openSubAssetModal: (subAsset = null, parentId = null, parentSubId = null) => modalManager.openSubAssetModal(subAsset, parentId, parentSubId),
|
|
deleteAsset,
|
|
deleteSubAsset,
|
|
createSubAssetElement,
|
|
handleSidebarNav,
|
|
renderSubAssets,
|
|
openDuplicateModal: (type, assetId = null) => duplicationManager.openDuplicateModal(type, assetId),
|
|
|
|
// Search functionality
|
|
searchInput,
|
|
renderAssetList,
|
|
|
|
// Global state
|
|
assets,
|
|
subAssets,
|
|
|
|
// DOM elements
|
|
assetList,
|
|
assetDetails,
|
|
subAssetContainer
|
|
});
|
|
|
|
// Initialize the list renderer module
|
|
initListRenderer({
|
|
// Module functions
|
|
updateSelectedIds,
|
|
renderAssetDetails,
|
|
handleSidebarNav,
|
|
formatDate,
|
|
formatCurrency,
|
|
|
|
// Global state
|
|
assets,
|
|
subAssets,
|
|
selectedAssetId,
|
|
dashboardFilter,
|
|
currentSort,
|
|
searchInput,
|
|
|
|
// DOM elements
|
|
assetList
|
|
});
|
|
|
|
const maintenanceManager = new MaintenanceManager();
|
|
const assetTagManager = setupTagInput('assetTags', 'assetTagsContainer');
|
|
const subAssetTagManager = setupTagInput('subAssetTags', 'subAssetTagsContainer');
|
|
|
|
// Initialize DuplicationManager before ModalManager
|
|
duplicationManager = new DuplicationManager({
|
|
// Utility functions
|
|
setButtonLoading,
|
|
generateId,
|
|
|
|
// Navigation functions
|
|
renderAssetDetails,
|
|
closeAssetModal: () => modalManager.closeAssetModal(),
|
|
closeSubAssetModal: () => modalManager.closeSubAssetModal(),
|
|
|
|
// Data functions
|
|
refreshData: refreshAllData,
|
|
getAssets: () => assets,
|
|
getSubAssets: () => subAssets
|
|
});
|
|
|
|
modalManager = new ModalManager({
|
|
// DOM elements
|
|
assetModal,
|
|
assetForm,
|
|
subAssetModal,
|
|
subAssetForm,
|
|
|
|
// Utility functions
|
|
formatDate,
|
|
formatCurrency,
|
|
formatFileSize,
|
|
generateId,
|
|
|
|
// File handling
|
|
handleFileUploads,
|
|
setupFilePreview,
|
|
setupExistingFilePreview,
|
|
formatFilePath,
|
|
|
|
// UI functions
|
|
setButtonLoading,
|
|
expandSection,
|
|
collapseSection,
|
|
|
|
// Data functions
|
|
saveAsset,
|
|
saveSubAsset,
|
|
|
|
// Navigation functions
|
|
renderAssetDetails,
|
|
|
|
// Tag and maintenance managers
|
|
assetTagManager,
|
|
subAssetTagManager,
|
|
maintenanceManager,
|
|
|
|
// Managers
|
|
duplicationManager,
|
|
|
|
// Global state
|
|
getAssets: () => assets,
|
|
getSubAssets: () => subAssets
|
|
});
|
|
|
|
// Initialize SettingsManager after DashboardManager is ready
|
|
if (settingsBtn && settingsModal && notificationForm && saveSettings && cancelSettings && settingsClose && testNotificationSettings) {
|
|
settingsManager = new SettingsManager({
|
|
settingsBtn,
|
|
settingsModal,
|
|
notificationForm,
|
|
saveSettings,
|
|
cancelSettings,
|
|
settingsClose,
|
|
testNotificationSettings,
|
|
setButtonLoading,
|
|
renderDashboard: (animate = true) => dashboardManager.renderDashboard(animate),
|
|
});
|
|
}
|
|
|
|
if (importModal && importBtn && importFile && startImportBtn) {
|
|
const importManager = new ImportManager({
|
|
importModal,
|
|
importBtn,
|
|
importFile,
|
|
startImportBtn,
|
|
columnSelects,
|
|
setButtonLoading,
|
|
loadAssets,
|
|
renderDashboard: (animate = true) => dashboardManager.renderDashboard(animate),
|
|
});
|
|
}
|
|
|
|
addElementEventListeners();
|
|
setupDragIcons();
|
|
addShortcutEventListeners();
|
|
|
|
registerServiceWorker();
|
|
}
|
|
// Initialize the application
|
|
try { // Global try-catch to catch any initialization errors
|
|
initialize();
|
|
} catch (err) {
|
|
globalThis.logError('Failed to initialize application. Please check the console for details.', err);
|
|
}
|
|
|
|
function addWindowEventListenersAndProperties() {
|
|
// Keep window references for backward compatibility with existing save functions
|
|
globalThis.deletePhoto = false;
|
|
globalThis.deleteReceipt = false;
|
|
globalThis.deleteManual = false;
|
|
globalThis.deleteSubPhoto = false;
|
|
globalThis.deleteSubReceipt = false;
|
|
globalThis.deleteSubManual = false;
|
|
globalThis.renderCardVisibilityToggles = renderCardVisibilityToggles;
|
|
window.initCollapsibleSections = initCollapsibleSections;
|
|
|
|
// Handle window resize events to update sidebar overlay
|
|
globalThis.addEventListener('resize', () => {
|
|
// If we're now in desktop mode but overlay is visible, hide it
|
|
if (globalThis.innerWidth > 853 && sidebarOverlay) {
|
|
sidebarOverlay.style.display = 'none';
|
|
sidebarOverlay.style.opacity = '0';
|
|
sidebarOverlay.style.pointerEvents = 'none';
|
|
}
|
|
// If we're now in mobile mode with sidebar open, show overlay
|
|
else if (globalThis.innerWidth <= 853 && sidebar && sidebar.classList.contains('open') && sidebarOverlay) {
|
|
sidebarOverlay.style.display = 'block';
|
|
sidebarOverlay.style.opacity = '1';
|
|
sidebarOverlay.style.pointerEvents = 'auto';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Button loading state handler
|
|
function setButtonLoading(button, loading) {
|
|
if (loading) {
|
|
button.classList.add('loading');
|
|
button.disabled = true;
|
|
} else {
|
|
button.classList.remove('loading');
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Data Functions
|
|
async function loadAssets() {
|
|
try {
|
|
const apiBaseUrl = globalThis.getApiBaseUrl();
|
|
const response = await fetch(`${apiBaseUrl}/api/assets`, {
|
|
credentials: 'include'
|
|
});
|
|
const responseValidation = await globalThis.validateResponse(response);
|
|
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
|
|
assets = await response.json();
|
|
// Update asset list in the modules
|
|
updateState(assets, subAssets);
|
|
updateListState(assets, subAssets, selectedAssetId);
|
|
renderAssetList();
|
|
} catch (error) {
|
|
globalThis.logError('Error loading assets:', error.message);
|
|
assets = [];
|
|
updateState(assets, subAssets);
|
|
updateListState(assets, subAssets, selectedAssetId);
|
|
renderAssetList();
|
|
}
|
|
}
|
|
|
|
async function loadSubAssets() {
|
|
try {
|
|
const apiBaseUrl = globalThis.getApiBaseUrl();
|
|
const response = await fetch(`${apiBaseUrl}/api/subassets`, {
|
|
credentials: 'include'
|
|
});
|
|
const responseValidation = await globalThis.validateResponse(response);
|
|
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
|
|
|
|
subAssets = await response.json();
|
|
updateState(assets, subAssets);
|
|
updateListState(assets, subAssets, selectedAssetId);
|
|
} catch (error) {
|
|
globalThis.logError('Error loading sub-assets:', error.message);
|
|
subAssets = [];
|
|
updateState(assets, subAssets);
|
|
updateListState(assets, subAssets, selectedAssetId);
|
|
}
|
|
}
|
|
|
|
// Load both assets and sub-assets
|
|
async function loadAllData() {
|
|
await Promise.all([loadAssets(), loadSubAssets()]);
|
|
}
|
|
|
|
// Also add a dedicated refresh function to reload data without resetting the UI
|
|
async function refreshAllData() {
|
|
try {
|
|
const loadPromises = [loadAssets(), loadSubAssets()];
|
|
await Promise.all(loadPromises);
|
|
return true;
|
|
} catch (error) {
|
|
globalThis.logError('Error refreshing data:', error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Expose refreshAllData to window for ModalManager access
|
|
window.refreshAllData = refreshAllData;
|
|
|
|
async function saveAsset(asset) {
|
|
const saveBtn = assetForm.querySelector('.save-btn');
|
|
|
|
try {
|
|
setButtonLoading(saveBtn, true);
|
|
const apiBaseUrl = globalThis.getApiBaseUrl();
|
|
// Get the actual edit mode from the modal manager instead of just checking for ID
|
|
const isEditMode = modalManager ? modalManager.isEditMode : false;
|
|
|
|
// Create a copy to avoid mutation issues
|
|
const assetToSave = { ...asset };
|
|
|
|
console.log('Starting saveAsset with data:', {
|
|
id: assetToSave.id,
|
|
name: assetToSave.name,
|
|
photoPath: assetToSave.photoPath,
|
|
receiptPath: assetToSave.receiptPath,
|
|
manualPath: assetToSave.manualPath
|
|
});
|
|
console.log('Edit mode determined as:', isEditMode);
|
|
|
|
|
|
console.log('After handling deletions, asset state:', {
|
|
photoPath: assetToSave.photoPath,
|
|
receiptPath: assetToSave.receiptPath,
|
|
manualPath: assetToSave.manualPath
|
|
});
|
|
|
|
// Make the API call to save the asset
|
|
const url = isEditMode ? `${apiBaseUrl}/api/assets/${assetToSave.id}` : `${apiBaseUrl}/api/asset`;
|
|
const response = await fetch(url, {
|
|
method: isEditMode ? 'PUT' : 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(assetToSave),
|
|
credentials: 'include'
|
|
});
|
|
|
|
const responseValidation = await globalThis.validateResponse(response);
|
|
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
|
|
|
|
// Get the saved asset from the response
|
|
const savedAsset = await response.json();
|
|
console.log('Asset saved successfully. Response data:', {
|
|
id: savedAsset.id,
|
|
name: savedAsset.name,
|
|
photoPath: savedAsset.photoPath,
|
|
receiptPath: savedAsset.receiptPath,
|
|
manualPath: savedAsset.manualPath
|
|
});
|
|
|
|
// Reload all data to ensure everything is updated
|
|
await refreshAllData();
|
|
|
|
// Close the modal
|
|
modalManager.closeAssetModal();
|
|
|
|
// Reset delete flags both locally and on window
|
|
deletePhoto = window.deletePhoto = false;
|
|
deleteReceipt = window.deleteReceipt = false;
|
|
deleteManual = window.deleteManual = false;
|
|
|
|
// Always explicitly render the asset details if it's the current selection
|
|
// or if this is a new asset that should be selected
|
|
if (selectedAssetId === assetToSave.id || !selectedAssetId) {
|
|
selectedAssetId = savedAsset.id;
|
|
|
|
// Force a refresh of the asset from the server data
|
|
const refreshedAsset = assets.find(a => a.id === savedAsset.id);
|
|
if (refreshedAsset) {
|
|
console.log('Refreshing asset display with data:', {
|
|
photoPath: refreshedAsset.photoPath,
|
|
receiptPath: refreshedAsset.receiptPath,
|
|
manualPath: refreshedAsset.manualPath
|
|
});
|
|
}
|
|
|
|
refreshAssetDetails(savedAsset.id, false);
|
|
}
|
|
|
|
// Show success message
|
|
globalThis.toaster.show(isEditMode ? "Asset updated successfully!" : "Asset added successfully!");
|
|
setButtonLoading(saveBtn, false);
|
|
} catch (error) {
|
|
globalThis.logError('Error saving asset:', error.message);
|
|
} finally {
|
|
setButtonLoading(saveBtn, false);
|
|
}
|
|
}
|
|
|
|
async function saveSubAsset(subAsset) {
|
|
const saveBtn = subAssetForm.querySelector('.save-btn');
|
|
|
|
try {
|
|
setButtonLoading(saveBtn, true);
|
|
const apiBaseUrl = globalThis.getApiBaseUrl();
|
|
// Get the actual edit mode from the modal manager instead of just checking for ID
|
|
const isEditMode = modalManager ? modalManager.isEditMode : false;
|
|
|
|
// Debug logging to see what we're sending
|
|
console.log('Saving sub-asset with data:', JSON.stringify(subAsset, null, 2));
|
|
console.log('Edit mode determined as:', isEditMode);
|
|
|
|
// Check for required fields that server expects
|
|
if (!subAsset.id) {
|
|
console.error('Missing required field: id');
|
|
}
|
|
if (!subAsset.name) {
|
|
console.error('Missing required field: name');
|
|
}
|
|
if (!subAsset.parentId) {
|
|
console.error('Missing required field: parentId');
|
|
}
|
|
// Ensure we're sending the required fields
|
|
if (!subAsset.id || !subAsset.name || !subAsset.parentId) {
|
|
throw new Error('Missing required fields for sub-asset. Check the console for details.');
|
|
}
|
|
|
|
const url = isEditMode ? `${apiBaseUrl}/api/subassets/${subAsset.id}` : `${apiBaseUrl}/api/subasset`;
|
|
const response = await fetch(url, {
|
|
method: isEditMode ? 'PUT' : 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(subAsset),
|
|
credentials: 'include'
|
|
});
|
|
|
|
const responseValidation = await globalThis.validateResponse(response);
|
|
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
|
|
|
|
// Get the updated sub-asset from the response
|
|
const savedSubAsset = await response.json();
|
|
console.log('Server response with updated sub-asset:', savedSubAsset);
|
|
|
|
// Reload all data to ensure everything is updated
|
|
await refreshAllData();
|
|
|
|
// Close the modal
|
|
modalManager.closeSubAssetModal();
|
|
|
|
// Determine which view to render after saving
|
|
if (subAsset.parentSubId) {
|
|
// If this is a sub-sub-asset, go to the parent sub-asset view
|
|
refreshAssetDetails(subAsset.parentSubId, true);
|
|
} else if (selectedSubAssetId === subAsset.id) {
|
|
// If we're editing the currently viewed sub-asset
|
|
refreshAssetDetails(subAsset.id, true);
|
|
} else {
|
|
// Navigate based on the saved component's context
|
|
await handleComponentNavigation({ id: savedSubAsset.id, parentId: savedSubAsset.parentId, parentSubId: savedSubAsset.parentSubId }, true);
|
|
}
|
|
|
|
// Show success message
|
|
globalThis.toaster.show(isEditMode ? "Component updated successfully!" : "Component added successfully!");
|
|
setButtonLoading(saveBtn, false);
|
|
} catch (error) {
|
|
globalThis.logError('Error saving component:', error.message);
|
|
} finally {
|
|
setButtonLoading(saveBtn, false);
|
|
}
|
|
}
|
|
|
|
async function deleteAsset(assetId) {
|
|
if (!confirm('Are you sure you want to delete this asset? This will also delete all its components.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const apiBaseUrl = globalThis.getApiBaseUrl();
|
|
const response = await fetch(`${apiBaseUrl}/api/asset/${assetId}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include'
|
|
});
|
|
|
|
const responseValidation = await globalThis.validateResponse(response);
|
|
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
|
|
|
|
updateSelectedIds(null, null);
|
|
await refreshAllData();
|
|
dashboardManager.renderDashboard();
|
|
globalThis.toaster.show("Asset deleted successfully!");
|
|
} catch (error) {
|
|
globalThis.logError('Error deleting asset:', error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteSubAsset(subAssetId) {
|
|
if (!confirm('Are you sure you want to delete this component? This will also delete any sub-components.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const apiBaseUrl = globalThis.getApiBaseUrl();
|
|
|
|
// Find the sub-asset and its parent info before deleting
|
|
const subAsset = subAssets.find(s => s.id === subAssetId);
|
|
if (!subAsset) {
|
|
throw new Error('Sub-asset not found');
|
|
}
|
|
|
|
// Store parent info for later
|
|
const parentAssetId = subAsset.parentId;
|
|
const parentSubId = subAsset.parentSubId;
|
|
|
|
const response = await fetch(`${apiBaseUrl}/api/subasset/${subAssetId}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include'
|
|
});
|
|
|
|
const responseValidation = await globalThis.validateResponse(response);
|
|
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
|
|
|
|
// Refresh all data first to ensure we have the latest state
|
|
await refreshAllData();
|
|
|
|
// Handle navigation and refresh views based on deleted component's context
|
|
await handleComponentNavigation({ id: subAssetId, parentId: parentAssetId, parentSubId }, true);
|
|
|
|
// If viewing the parent sub-asset or asset, refresh the current view
|
|
if (selectedSubAssetId === parentSubId) {
|
|
await refreshAssetDetails(parentSubId, true);
|
|
} else if (selectedAssetId === parentAssetId && !selectedSubAssetId) {
|
|
await refreshAssetDetails(parentAssetId, false);
|
|
}
|
|
|
|
globalThis.toaster.show("Component deleted successfully!");
|
|
} catch (error) {
|
|
globalThis.logError('Error deleting component:', error.message);
|
|
}
|
|
}
|
|
|
|
// Utility function to handle component navigation and rendering logic
|
|
async function handleComponentNavigation(component, isDeleted = false) {
|
|
const parentAssetId = component.parentId;
|
|
const parentSubId = component.parentSubId;
|
|
|
|
// Case 1: If the component was being viewed when deleted
|
|
// Or if it's a new/updated component and we want to show it
|
|
if (!isDeleted && (component.id === selectedSubAssetId || !parentSubId)) {
|
|
updateSelectedIds(parentAssetId, component.id);
|
|
await refreshAssetDetails(component.id, true);
|
|
return;
|
|
}
|
|
|
|
// Case 2: Navigate to parent sub-asset if this was a sub-sub-asset
|
|
if (parentSubId) {
|
|
updateSelectedIds(parentAssetId, parentSubId);
|
|
await refreshAssetDetails(parentSubId, true);
|
|
return;
|
|
}
|
|
|
|
// Case 3: Navigate to parent asset
|
|
updateSelectedIds(parentAssetId, null);
|
|
await refreshAssetDetails(parentAssetId, false);
|
|
}
|
|
|
|
function closeSidebar() {
|
|
if (sidebar) sidebar.classList.remove('open');
|
|
if (sidebarCloseBtn) sidebarCloseBtn.style.display = 'none';
|
|
if (sidebarOpenBtn) sidebarOpenBtn.style.display = 'block';
|
|
if (sidebarToggle) sidebarToggle.style.display = 'block';
|
|
document.querySelector('.app-container').classList.remove('sidebar-active');
|
|
|
|
// Hide overlay directly with JavaScript for cross-browser compatibility
|
|
if (sidebarOverlay) {
|
|
sidebarOverlay.style.display = 'none';
|
|
sidebarOverlay.style.opacity = '0';
|
|
sidebarOverlay.style.pointerEvents = 'none';
|
|
}
|
|
}
|
|
|
|
function openSidebar() {
|
|
if (sidebar) sidebar.classList.add('open');
|
|
if (sidebarCloseBtn) sidebarCloseBtn.style.display = 'block';
|
|
if (sidebarOpenBtn) sidebarOpenBtn.style.display = 'none';
|
|
document.querySelector('.app-container').classList.add('sidebar-active');
|
|
|
|
// Show overlay directly with JavaScript for cross-browser compatibility
|
|
// Only in mobile view (width <= 853px)
|
|
if (sidebarOverlay && window.innerWidth <= 853) {
|
|
sidebarOverlay.style.display = 'block';
|
|
sidebarOverlay.style.opacity = '1';
|
|
sidebarOverlay.style.pointerEvents = 'auto';
|
|
}
|
|
}
|
|
|
|
function handleSidebarNav() {
|
|
if (window.innerWidth <= 853) closeSidebar();
|
|
}
|
|
|
|
// Sorting Functions
|
|
function updateSortButtons(activeButton) {
|
|
// Remove active class from all buttons
|
|
document.querySelectorAll('.sort-button').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
});
|
|
|
|
// Set active button and update its direction
|
|
if (activeButton) {
|
|
activeButton.classList.add('active');
|
|
const direction = activeButton.getAttribute('data-direction');
|
|
const sortIcon = activeButton.querySelector('.sort-icon');
|
|
if (sortIcon) {
|
|
sortIcon.style.transform = direction === 'desc' ? 'rotate(180deg)' : '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderSubAssets(parentAssetId) {
|
|
if (!subAssetContainer || !subAssetList) return;
|
|
|
|
// Get sub-assets for this parent
|
|
const parentSubAssets = subAssets.filter(sa => sa.parentId === parentAssetId && !sa.parentSubId);
|
|
|
|
|
|
// Render the sub-asset header (the + Add Component button)
|
|
const subAssetHeader = subAssetContainer.querySelector('.sub-asset-header');
|
|
if (subAssetHeader) {
|
|
subAssetHeader.innerHTML = `
|
|
<button id="addSubAssetBtn" class="add-sub-asset-btn">+ Add Component</button>
|
|
`;
|
|
const addSubAssetBtn = subAssetHeader.querySelector('#addSubAssetBtn');
|
|
if (addSubAssetBtn) {
|
|
addSubAssetBtn.onclick = () => modalManager.openSubAssetModal(null, parentAssetId);
|
|
}
|
|
}
|
|
|
|
// Render the sub-asset list
|
|
if (parentSubAssets.length === 0) {
|
|
subAssetList.innerHTML = `
|
|
<div class="empty-state">
|
|
<p>No components found. Add your first component.</p>
|
|
</div>
|
|
`;
|
|
} else {
|
|
subAssetList.innerHTML = '';
|
|
parentSubAssets.forEach(subAsset => {
|
|
const subAssetElement = createSubAssetElement(subAsset);
|
|
subAssetList.appendChild(subAssetElement);
|
|
});
|
|
}
|
|
|
|
// Show or hide the container
|
|
subAssetContainer.classList.remove('hidden');
|
|
}
|
|
|
|
function createSubAssetElement(subAsset) {
|
|
const element = document.createElement('div');
|
|
element.className = 'sub-asset-item';
|
|
if (subAsset.id === selectedSubAssetId) {
|
|
element.classList.add('active');
|
|
}
|
|
|
|
// Check warranty expiration
|
|
let warrantyDot = '';
|
|
if (subAsset.warranty && subAsset.warranty.expirationDate) {
|
|
const expDate = new Date(formatDate(subAsset.warranty.expirationDate));
|
|
const now = new Date();
|
|
const diff = (expDate - now) / (1000 * 60 * 60 * 24); // difference in days
|
|
|
|
if (diff < 0) {
|
|
// Warranty has expired
|
|
warrantyDot = `<div class="warranty-expired-icon">
|
|
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="15" y1="9" x2="9" y2="15" />
|
|
<line x1="9" y1="9" x2="15" y2="15" />
|
|
</svg>
|
|
</div>`;
|
|
} else if (diff >= 0 && diff <= 30) {
|
|
warrantyDot = '<div class="warranty-expiring-dot"></div>';
|
|
} else if (diff > 30 && diff <= 60) {
|
|
warrantyDot = '<div class="warranty-warning-dot"></div>';
|
|
}
|
|
}
|
|
|
|
// Create header with name and actions
|
|
const details = document.createElement('div');
|
|
details.className = 'sub-asset-details';
|
|
details.innerHTML = `
|
|
${warrantyDot}
|
|
<div class="sub-asset-title">${subAsset.name}</div>
|
|
<div class="sub-asset-actions">
|
|
<button class="edit-sub-btn" data-id="${subAsset.id}" title="Edit">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19.5 3 21l1.5-4L16.5 3.5z"/></svg>
|
|
</button>
|
|
<button class="duplicate-sub-btn" data-id="${subAsset.id}" title="Duplicate">
|
|
<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>
|
|
</button>
|
|
<button class="delete-sub-btn" data-id="${subAsset.id}" title="Delete">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Set up button event listeners
|
|
const duplicateBtn = details.querySelector('.duplicate-sub-btn');
|
|
duplicateBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
duplicationManager.openDuplicateModal('subAsset', subAsset.id);
|
|
});
|
|
|
|
const editBtn = details.querySelector('.edit-sub-btn');
|
|
editBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
modalManager.openSubAssetModal(subAsset);
|
|
});
|
|
|
|
const deleteBtn = details.querySelector('.delete-sub-btn');
|
|
deleteBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
deleteSubAsset(subAsset.id);
|
|
});
|
|
|
|
element.appendChild(details);
|
|
|
|
// Add summary info
|
|
const info = document.createElement('div');
|
|
info.className = 'sub-asset-info';
|
|
|
|
// Create model/serial info and tags section
|
|
info.innerHTML = `
|
|
<div>
|
|
${subAsset.modelNumber ? `<span>${subAsset.modelNumber}</span>` : ''}
|
|
${subAsset.serialNumber ? `<span>#${subAsset.serialNumber}</span>` : ''}
|
|
</div>
|
|
${subAsset.tags && subAsset.tags.length > 0 ? `
|
|
<div class="tag-list">
|
|
${subAsset.tags.map(tag => `<span class="tag" data-tag="${tag}">${tag}</span>`).join('')}
|
|
</div>`: ''}
|
|
`;
|
|
|
|
element.appendChild(info);
|
|
|
|
// Add click event listeners to tags
|
|
const tagElements = info.querySelectorAll('.tag');
|
|
tagElements.forEach(tagElement => {
|
|
tagElement.addEventListener('click', (e) => {
|
|
e.stopPropagation(); // Prevent sub-asset click
|
|
const tagName = tagElement.dataset.tag;
|
|
|
|
// Set the search input value to the tag name
|
|
if (searchInput) {
|
|
searchInput.value = tagName;
|
|
|
|
// Show the clear search button
|
|
const clearSearchBtn = document.getElementById('clearSearchBtn');
|
|
if (clearSearchBtn) {
|
|
clearSearchBtn.style.display = 'flex';
|
|
}
|
|
|
|
// Trigger the search by calling renderAssetList with the tag
|
|
renderAssetList(tagName);
|
|
|
|
// Focus the search input
|
|
searchInput.focus();
|
|
}
|
|
});
|
|
|
|
// Add cursor pointer style to make it clear tags are clickable
|
|
tagElement.style.cursor = 'pointer';
|
|
});
|
|
|
|
// Add file previews
|
|
const filePreviewsContainer = document.createElement('div');
|
|
filePreviewsContainer.className = 'sub-asset-files';
|
|
|
|
// Check if sub-asset has any files (both single files and arrays)
|
|
const hasFiles = subAsset.photoPath || subAsset.receiptPath || subAsset.manualPath ||
|
|
(subAsset.photoPaths && subAsset.photoPaths.length > 0) ||
|
|
(subAsset.receiptPaths && subAsset.receiptPaths.length > 0) ||
|
|
(subAsset.manualPaths && subAsset.manualPaths.length > 0);
|
|
|
|
if (hasFiles) {
|
|
const files = document.createElement('div');
|
|
files.className = 'compact-files-grid';
|
|
|
|
// Handle multiple photos first, then fallback to single photo
|
|
if (subAsset.photoPaths && Array.isArray(subAsset.photoPaths) && subAsset.photoPaths.length > 0) {
|
|
subAsset.photoPaths.forEach((photoPath, index) => {
|
|
files.innerHTML += `
|
|
<div class="compact-file-item photo">
|
|
<a href="${formatFilePath(photoPath)}" target="_blank">
|
|
<img src="${formatFilePath(photoPath)}" alt="${subAsset.name}" class="compact-asset-image">
|
|
</a>
|
|
</div>
|
|
`;
|
|
});
|
|
} else if (subAsset.photoPath) {
|
|
files.innerHTML += `
|
|
<div class="compact-file-item photo">
|
|
<a href="${formatFilePath(subAsset.photoPath)}" target="_blank">
|
|
<img src="${formatFilePath(subAsset.photoPath)}" alt="${subAsset.name}" class="compact-asset-image">
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Handle multiple receipts first, then fallback to single receipt
|
|
if (subAsset.receiptPaths && Array.isArray(subAsset.receiptPaths) && subAsset.receiptPaths.length > 0) {
|
|
subAsset.receiptPaths.forEach((receiptPath, index) => {
|
|
files.innerHTML += `
|
|
<div class="compact-file-item receipt">
|
|
<a href="${formatFilePath(receiptPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
<path d="M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16l-3 -2l-2 2l-2 -2l-2 2l-2 -2l-3 2"/>
|
|
<path d="M14 8h-8"/>
|
|
<path d="M15 12h-9"/>
|
|
<path d="M15 16h-9"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
});
|
|
} else if (subAsset.receiptPath) {
|
|
files.innerHTML += `
|
|
<div class="compact-file-item receipt">
|
|
<a href="${formatFilePath(subAsset.receiptPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
<path d="M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16l-3 -2l-2 2l-2 -2l-2 2l-2 -2l-3 2"/>
|
|
<path d="M14 8h-8"/>
|
|
<path d="M15 12h-9"/>
|
|
<path d="M15 16h-9"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Handle multiple manuals first, then fallback to single manual
|
|
if (subAsset.manualPaths && Array.isArray(subAsset.manualPaths) && subAsset.manualPaths.length > 0) {
|
|
subAsset.manualPaths.forEach((manualPath, index) => {
|
|
files.innerHTML += `
|
|
<div class="compact-file-item manual">
|
|
<a href="${formatFilePath(manualPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<path d="M14 2v6h6"/>
|
|
<path d="M16 13H8"/>
|
|
<path d="M16 17H8"/>
|
|
<path d="M10 9H8"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
});
|
|
} else if (subAsset.manualPath) {
|
|
files.innerHTML += `
|
|
<div class="compact-file-item manual">
|
|
<a href="${formatFilePath(subAsset.manualPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<path d="M14 2v6h6"/>
|
|
<path d="M16 13H8"/>
|
|
<path d="M16 17H8"/>
|
|
<path d="M10 9H8"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
filePreviewsContainer.appendChild(files);
|
|
}
|
|
|
|
element.appendChild(filePreviewsContainer);
|
|
|
|
// Check for children (only for first level sub-assets)
|
|
if (!subAsset.parentSubId) {
|
|
const children = subAssets.filter(sa => sa.parentSubId === subAsset.id);
|
|
|
|
if (children.length > 0) {
|
|
const childrenContainer = document.createElement('div');
|
|
childrenContainer.className = 'sub-asset-children';
|
|
|
|
children.forEach(child => {
|
|
const childElement = document.createElement('div');
|
|
childElement.className = 'sub-asset-item child';
|
|
|
|
// Check warranty expiration for child
|
|
let childWarrantyDot = '';
|
|
if (child.warranty && child.warranty.expirationDate) {
|
|
const expDate = new Date(formatDate(child.warranty.expirationDate));
|
|
const now = new Date();
|
|
const diff = (expDate - now) / (1000 * 60 * 60 * 24);
|
|
|
|
if (diff < 0) {
|
|
// Warranty has expired
|
|
childWarrantyDot = `<div class="warranty-expired-icon">
|
|
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="15" y1="9" x2="9" y2="15" />
|
|
<line x1="9" y1="9" x2="15" y2="15" />
|
|
</svg>
|
|
</div>`;
|
|
} else if (diff >= 0 && diff <= 30) {
|
|
childWarrantyDot = '<div class="warranty-expiring-dot"></div>';
|
|
} else if (diff > 30 && diff <= 60) {
|
|
childWarrantyDot = '<div class="warranty-warning-dot"></div>';
|
|
}
|
|
}
|
|
|
|
// Create child header
|
|
const childDetails = document.createElement('div');
|
|
childDetails.className = 'sub-asset-details';
|
|
childDetails.innerHTML = `
|
|
${childWarrantyDot}
|
|
<div class="sub-asset-title">${child.name}</div>
|
|
<div class="sub-asset-actions">
|
|
<button class="edit-sub-btn" data-id="${child.id}" title="Edit">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19.5 3 21l1.5-4L16.5 3.5z"/></svg>
|
|
</button>
|
|
<button class="duplicate-sub-btn" data-id="${child.id}" title="Duplicate">
|
|
<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>
|
|
</button>
|
|
<button class="delete-sub-btn" data-id="${child.id}" title="Delete">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
childElement.appendChild(childDetails);
|
|
|
|
// Add child info section with tags
|
|
const childInfo = document.createElement('div');
|
|
childInfo.className = 'sub-asset-info';
|
|
childInfo.innerHTML = `
|
|
<div>
|
|
${child.modelNumber ? `<span>${child.modelNumber}</span>` : ''}
|
|
${child.serialNumber ? `<span>#${child.serialNumber}</span>` : ''}
|
|
</div>
|
|
${child.tags && child.tags.length > 0 ? `
|
|
<div class="tag-list">
|
|
${child.tags.map(tag => `<span class="tag" data-tag="${tag}">${tag}</span>`).join('')}
|
|
</div>`: ''}
|
|
`;
|
|
childElement.appendChild(childInfo);
|
|
|
|
// Add click event listeners to tags
|
|
const tagElements = childInfo.querySelectorAll('.tag');
|
|
tagElements.forEach(tagElement => {
|
|
tagElement.addEventListener('click', (e) => {
|
|
e.stopPropagation(); // Prevent sub-asset click
|
|
const tagName = tagElement.dataset.tag;
|
|
|
|
// Set the search input value to the tag name
|
|
if (searchInput) {
|
|
searchInput.value = tagName;
|
|
|
|
// Show the clear search button
|
|
const clearSearchBtn = document.getElementById('clearSearchBtn');
|
|
if (clearSearchBtn) {
|
|
clearSearchBtn.style.display = 'flex';
|
|
}
|
|
|
|
// Trigger the search by calling renderAssetList with the tag
|
|
renderAssetList(tagName);
|
|
|
|
// Focus the search input
|
|
searchInput.focus();
|
|
}
|
|
});
|
|
|
|
// Add cursor pointer style to make it clear tags are clickable
|
|
tagElement.style.cursor = 'pointer';
|
|
});
|
|
|
|
// Add file previews container for the child
|
|
const childFilePreviewsContainer = document.createElement('div');
|
|
childFilePreviewsContainer.className = 'sub-asset-files';
|
|
|
|
// Check if child has any files (both single files and arrays)
|
|
const childHasFiles = child.photoPath || child.receiptPath || child.manualPath ||
|
|
(child.photoPaths && child.photoPaths.length > 0) ||
|
|
(child.receiptPaths && child.receiptPaths.length > 0) ||
|
|
(child.manualPaths && child.manualPaths.length > 0);
|
|
|
|
if (childHasFiles) {
|
|
const childFiles = document.createElement('div');
|
|
childFiles.className = 'compact-files-grid';
|
|
|
|
// Handle multiple photos first, then fallback to single photo
|
|
if (child.photoPaths && Array.isArray(child.photoPaths) && child.photoPaths.length > 0) {
|
|
child.photoPaths.forEach((photoPath, index) => {
|
|
childFiles.innerHTML += `
|
|
<div class="compact-file-item photo">
|
|
<a href="${formatFilePath(photoPath)}" target="_blank">
|
|
<img src="${formatFilePath(photoPath)}" alt="${child.name}" class="compact-asset-image">
|
|
</a>
|
|
</div>
|
|
`;
|
|
});
|
|
} else if (child.photoPath) {
|
|
childFiles.innerHTML += `
|
|
<div class="compact-file-item photo">
|
|
<a href="${formatFilePath(child.photoPath)}" target="_blank">
|
|
<img src="${formatFilePath(child.photoPath)}" alt="${child.name}" class="compact-asset-image">
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Handle multiple receipts first, then fallback to single receipt
|
|
if (child.receiptPaths && Array.isArray(child.receiptPaths) && child.receiptPaths.length > 0) {
|
|
child.receiptPaths.forEach((receiptPath, index) => {
|
|
childFiles.innerHTML += `
|
|
<div class="compact-file-item receipt">
|
|
<a href="${formatFilePath(receiptPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
<path d="M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16l-3 -2l-2 2l-2 -2l-2 2l-2 -2l-3 2"/>
|
|
<path d="M14 8h-8"/>
|
|
<path d="M15 12h-9"/>
|
|
<path d="M15 16h-9"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
});
|
|
} else if (child.receiptPath) {
|
|
childFiles.innerHTML += `
|
|
<div class="compact-file-item receipt">
|
|
<a href="${formatFilePath(child.receiptPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
<path d="M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16l-3 -2l-2 2l-2 -2l-2 2l-2 -2l-3 2"/>
|
|
<path d="M14 8h-8"/>
|
|
<path d="M15 12h-9"/>
|
|
<path d="M15 16h-9"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Handle multiple manuals first, then fallback to single manual
|
|
if (child.manualPaths && Array.isArray(child.manualPaths) && child.manualPaths.length > 0) {
|
|
child.manualPaths.forEach((manualPath, index) => {
|
|
childFiles.innerHTML += `
|
|
<div class="compact-file-item manual">
|
|
<a href="${formatFilePath(manualPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<path d="M14 2v6h6"/>
|
|
<path d="M16 13H8"/>
|
|
<path d="M16 17H8"/>
|
|
<path d="M10 9H8"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
});
|
|
} else if (child.manualPath) {
|
|
childFiles.innerHTML += `
|
|
<div class="compact-file-item manual">
|
|
<a href="${formatFilePath(child.manualPath)}" target="_blank">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<path d="M14 2v6h6"/>
|
|
<path d="M16 13H8"/>
|
|
<path d="M16 17H8"/>
|
|
<path d="M10 9H8"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
childFilePreviewsContainer.appendChild(childFiles);
|
|
}
|
|
childElement.appendChild(childFilePreviewsContainer);
|
|
|
|
// Add event listeners to child
|
|
const childDuplicateBtn = childElement.querySelector('.duplicate-sub-btn');
|
|
childDuplicateBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
duplicationManager.openDuplicateModal('subAsset', child.id);
|
|
});
|
|
|
|
const childEditBtn = childElement.querySelector('.edit-sub-btn');
|
|
childEditBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
modalManager.openSubAssetModal(child);
|
|
});
|
|
|
|
const childDeleteBtn = childElement.querySelector('.delete-sub-btn');
|
|
childDeleteBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
deleteSubAsset(child.id);
|
|
});
|
|
|
|
// Make sub-sub-asset clickable to show details
|
|
childElement.addEventListener('click', (e) => {
|
|
if (e.target.closest('button')) return;
|
|
e.stopPropagation();
|
|
updateSelectedIds(selectedAssetId, child.id);
|
|
renderAssetDetails(child.id, true);
|
|
});
|
|
|
|
childrenContainer.appendChild(childElement);
|
|
});
|
|
|
|
element.appendChild(childrenContainer);
|
|
}
|
|
}
|
|
|
|
// Make the component clickable to show details
|
|
element.addEventListener('click', (e) => {
|
|
// Prevent click if clicking on an action button
|
|
if (e.target.closest('button')) return;
|
|
e.stopPropagation();
|
|
updateSelectedIds(selectedAssetId, subAsset.id);
|
|
renderAssetDetails(subAsset.id, true);
|
|
});
|
|
|
|
return element;
|
|
}
|
|
|
|
// Add/enhance function to render asset details properly
|
|
async function refreshAssetDetails(assetId, isSubAsset = false) {
|
|
console.log(`Refreshing ${isSubAsset ? 'sub-asset' : 'asset'} details for ID: ${assetId}`);
|
|
|
|
if (!assetId) {
|
|
console.log('No ID provided for refresh');
|
|
return;
|
|
}
|
|
|
|
// Ensure we have fresh data before proceeding
|
|
const collection = isSubAsset ? subAssets : assets;
|
|
const item = collection.find(a => a.id === assetId);
|
|
|
|
if (!item) {
|
|
console.error(`Could not find ${isSubAsset ? 'sub-asset' : 'asset'} with ID: ${assetId}`);
|
|
// If item not found and we're refreshing a sub-asset, try to refresh the parent asset instead
|
|
if (isSubAsset) {
|
|
const parentAssetId = collection.find(a => a.id === assetId)?.parentId;
|
|
if (parentAssetId) {
|
|
console.log('Falling back to parent asset view');
|
|
refreshAssetDetails(parentAssetId, false);
|
|
return;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Log item details for debugging
|
|
console.log(`Found ${isSubAsset ? 'sub-asset' : 'asset'} data:`, {
|
|
id: item.id,
|
|
name: item.name,
|
|
photoPath: item.photoPath,
|
|
receiptPath: item.receiptPath,
|
|
manualPath: item.manualPath,
|
|
updatedAt: item.updatedAt
|
|
});
|
|
|
|
// Ensure that any image paths are properly formatted
|
|
if (item.photoPath) {
|
|
console.log(`Original photo path: ${item.photoPath}`);
|
|
const formattedPhotoPath = formatFilePath(item.photoPath);
|
|
console.log(`Formatted photo path: ${formattedPhotoPath}`);
|
|
} else {
|
|
console.log(`No photo path found for asset ${item.id}`);
|
|
}
|
|
|
|
if (item.receiptPath) {
|
|
console.log(`Original receipt path: ${item.receiptPath}`);
|
|
const formattedReceiptPath = formatFilePath(item.receiptPath);
|
|
console.log(`Formatted receipt path: ${formattedReceiptPath}`);
|
|
}
|
|
|
|
if (item.manualPath) {
|
|
console.log(`Original manual path: ${item.manualPath}`);
|
|
const formattedManualPath = formatFilePath(item.manualPath);
|
|
console.log(`Formatted manual path: ${formattedManualPath}`);
|
|
}
|
|
|
|
// Add secondary warranty info if it exists
|
|
let detailsHtml = '';
|
|
if (item.secondaryWarranty) {
|
|
detailsHtml += `
|
|
<div class="info-item">
|
|
<div class="info-label">Secondary Warranty</div>
|
|
<div>${item.secondaryWarranty.scope || 'N/A'}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">Secondary Warranty Expiration</div>
|
|
<div>${formatDate(item.secondaryWarranty.expirationDate)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render the details with a brief delay to ensure the DOM is ready
|
|
// and any data changes are fully applied
|
|
setTimeout(() => {
|
|
console.log(`Rendering details for ${isSubAsset ? 'sub-asset' : 'asset'} ${item.id}`);
|
|
renderAssetDetails(assetId, isSubAsset);
|
|
}, 50);
|
|
}
|
|
|
|
function setupDragIcons() {
|
|
// --- Inject SVG into .sortable-handle elements (Settings UI) ---
|
|
const sortableHandleSVG = `
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
<path d="M19 11v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" />
|
|
<path d="M13 13l9 3l-4 2l-2 4l-3 -9" />
|
|
<path d="M3 3l0 .01" />
|
|
<path d="M7 3l0 .01" />
|
|
<path d="M11 3l0 .01" />
|
|
<path d="M15 3l0 .01" />
|
|
<path d="M3 7l0 .01" />
|
|
<path d="M3 11l0 .01" />
|
|
<path d="M3 15l0 .01" />
|
|
</svg>
|
|
`;
|
|
document.querySelectorAll('.sortable-handle').forEach(handle => {
|
|
handle.innerHTML = sortableHandleSVG;
|
|
});
|
|
}
|
|
|
|
// Tag management functions
|
|
function getAllExistingTags() {
|
|
const allTags = new Set();
|
|
|
|
// Collect tags from assets
|
|
assets.forEach(asset => {
|
|
if (asset.tags && Array.isArray(asset.tags)) {
|
|
asset.tags.forEach(tag => allTags.add(tag));
|
|
}
|
|
});
|
|
|
|
// Collect tags from sub-assets
|
|
subAssets.forEach(subAsset => {
|
|
if (subAsset.tags && Array.isArray(subAsset.tags)) {
|
|
subAsset.tags.forEach(tag => allTags.add(tag));
|
|
}
|
|
});
|
|
|
|
return Array.from(allTags).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
}
|
|
|
|
function setupTagInput(inputId, containerId) {
|
|
const tags = new Set();
|
|
const input = document.getElementById(inputId);
|
|
const container = document.getElementById(containerId);
|
|
|
|
let autocompleteContainer = null;
|
|
let currentSuggestions = [];
|
|
let selectedSuggestionIndex = -1;
|
|
|
|
function createAutocompleteContainer() {
|
|
if (autocompleteContainer) {
|
|
autocompleteContainer.remove();
|
|
}
|
|
|
|
autocompleteContainer = document.createElement('div');
|
|
autocompleteContainer.className = 'tag-autocomplete';
|
|
autocompleteContainer.style.display = 'none';
|
|
autocompleteContainer.style.position = 'absolute';
|
|
autocompleteContainer.style.zIndex = '1000';
|
|
|
|
// Position the autocomplete container directly below the input
|
|
const inputRect = input.getBoundingClientRect();
|
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
|
|
|
autocompleteContainer.style.top = (inputRect.bottom + scrollTop) + 'px';
|
|
autocompleteContainer.style.left = (inputRect.left + scrollLeft) + 'px';
|
|
autocompleteContainer.style.width = inputRect.width + 'px';
|
|
|
|
// Append to body for proper positioning
|
|
document.body.appendChild(autocompleteContainer);
|
|
}
|
|
|
|
function showAutocomplete(suggestions) {
|
|
if (!autocompleteContainer) {
|
|
createAutocompleteContainer();
|
|
}
|
|
|
|
currentSuggestions = suggestions;
|
|
selectedSuggestionIndex = -1;
|
|
|
|
if (suggestions.length === 0) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
// Update position before showing (in case input moved)
|
|
const inputRect = input.getBoundingClientRect();
|
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
|
|
|
autocompleteContainer.style.top = (inputRect.bottom + scrollTop) + 'px';
|
|
autocompleteContainer.style.left = (inputRect.left + scrollLeft) + 'px';
|
|
autocompleteContainer.style.width = inputRect.width + 'px';
|
|
|
|
autocompleteContainer.innerHTML = suggestions.map((suggestion, index) =>
|
|
`<div class="tag-suggestion" data-index="${index}">${suggestion}</div>`
|
|
).join('');
|
|
|
|
autocompleteContainer.style.display = 'block';
|
|
|
|
// Add click handlers to suggestions
|
|
autocompleteContainer.querySelectorAll('.tag-suggestion').forEach((suggestionElement, index) => {
|
|
suggestionElement.addEventListener('click', () => {
|
|
selectSuggestion(index);
|
|
});
|
|
});
|
|
}
|
|
|
|
function hideAutocomplete() {
|
|
if (autocompleteContainer) {
|
|
autocompleteContainer.style.display = 'none';
|
|
}
|
|
currentSuggestions = [];
|
|
selectedSuggestionIndex = -1;
|
|
}
|
|
|
|
function updateSuggestionSelection() {
|
|
if (!autocompleteContainer) return;
|
|
|
|
autocompleteContainer.querySelectorAll('.tag-suggestion').forEach((el, index) => {
|
|
el.classList.toggle('selected', index === selectedSuggestionIndex);
|
|
});
|
|
}
|
|
|
|
function selectSuggestion(index) {
|
|
if (index >= 0 && index < currentSuggestions.length) {
|
|
const selectedTag = currentSuggestions[index];
|
|
if (!tags.has(selectedTag)) {
|
|
tags.add(selectedTag);
|
|
input.value = '';
|
|
renderTags();
|
|
hideAutocomplete();
|
|
}
|
|
}
|
|
}
|
|
|
|
function getFilteredSuggestions(inputValue) {
|
|
if (!inputValue.trim()) return [];
|
|
|
|
const existingTags = getAllExistingTags();
|
|
const inputLower = inputValue.toLowerCase();
|
|
|
|
return existingTags.filter(tag =>
|
|
tag.toLowerCase().startsWith(inputLower) && !tags.has(tag)
|
|
).slice(0, 8); // Limit to 8 suggestions
|
|
}
|
|
|
|
function renderTags() {
|
|
if (!container) return;
|
|
container.innerHTML = Array.from(tags).map(tag => `
|
|
<span class="tag">
|
|
${tag}
|
|
<button class="remove-tag" data-tag="${tag}" title="Remove tag">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
`).join('');
|
|
|
|
// Add click handlers to remove buttons
|
|
container.querySelectorAll('.remove-tag').forEach(btn => {
|
|
btn.onclick = (e) => {
|
|
e.stopPropagation(); // Prevent bubbling to parent elements
|
|
e.preventDefault();
|
|
const tagToRemove = btn.dataset.tag;
|
|
tags.delete(tagToRemove);
|
|
renderTags();
|
|
};
|
|
});
|
|
}
|
|
|
|
if (input) {
|
|
// Create autocomplete container
|
|
createAutocompleteContainer();
|
|
|
|
// Handle input changes for autocomplete
|
|
input.addEventListener('input', (e) => {
|
|
const value = e.target.value;
|
|
|
|
// Handle comma input for tag separation
|
|
if (value.endsWith(',')) {
|
|
e.preventDefault();
|
|
const tag = value.slice(0, -1).trim();
|
|
if (tag && !tags.has(tag)) {
|
|
tags.add(tag);
|
|
input.value = '';
|
|
renderTags();
|
|
hideAutocomplete();
|
|
}
|
|
} else {
|
|
// Show autocomplete suggestions
|
|
const suggestions = getFilteredSuggestions(value);
|
|
showAutocomplete(suggestions);
|
|
}
|
|
});
|
|
|
|
// Handle keyboard events
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ',') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// If there's a selected suggestion, use it
|
|
if (selectedSuggestionIndex >= 0 && currentSuggestions.length > 0) {
|
|
selectSuggestion(selectedSuggestionIndex);
|
|
} else {
|
|
// Otherwise, add the current input value
|
|
const tag = input.value.trim();
|
|
if (tag && !tags.has(tag)) {
|
|
tags.add(tag);
|
|
input.value = '';
|
|
renderTags();
|
|
hideAutocomplete();
|
|
}
|
|
}
|
|
} else if (e.key === 'Tab') {
|
|
// Tab completion
|
|
if (currentSuggestions.length > 0) {
|
|
e.preventDefault();
|
|
const suggestionIndex = selectedSuggestionIndex >= 0 ? selectedSuggestionIndex : 0;
|
|
selectSuggestion(suggestionIndex);
|
|
}
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (currentSuggestions.length > 0) {
|
|
selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, currentSuggestions.length - 1);
|
|
updateSuggestionSelection();
|
|
}
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (currentSuggestions.length > 0) {
|
|
selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, -1);
|
|
updateSuggestionSelection();
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
hideAutocomplete();
|
|
}
|
|
});
|
|
|
|
// Handle mobile keyboard "Enter" button
|
|
input.addEventListener('keyup', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// If there's a selected suggestion, use it
|
|
if (selectedSuggestionIndex >= 0 && currentSuggestions.length > 0) {
|
|
selectSuggestion(selectedSuggestionIndex);
|
|
} else {
|
|
// Otherwise, add the current input value
|
|
const tag = input.value.trim();
|
|
if (tag && !tags.has(tag)) {
|
|
tags.add(tag);
|
|
input.value = '';
|
|
renderTags();
|
|
hideAutocomplete();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Hide autocomplete when input loses focus (with a small delay)
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => {
|
|
hideAutocomplete();
|
|
}, 150); // Small delay to allow clicking on suggestions
|
|
});
|
|
|
|
// Show autocomplete when input gains focus (if there's content)
|
|
input.addEventListener('focus', () => {
|
|
if (input.value.trim()) {
|
|
const suggestions = getFilteredSuggestions(input.value);
|
|
showAutocomplete(suggestions);
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
getTags: () => Array.from(tags),
|
|
setTags: (newTags) => {
|
|
tags.clear();
|
|
newTags.forEach(tag => tags.add(tag));
|
|
renderTags();
|
|
},
|
|
addTag: (tag) => {
|
|
tags.add(tag);
|
|
renderTags();
|
|
},
|
|
removeTag: (tag) => {
|
|
tags.delete(tag);
|
|
renderTags();
|
|
},
|
|
clearTags: () => {
|
|
tags.clear();
|
|
renderTags();
|
|
}
|
|
};
|
|
}
|
|
|
|
// Add per-card visibility toggles in the settings modal (interface tab)
|
|
// This should be called when rendering the settings modal tabs
|
|
function renderCardVisibilityToggles(settings) {
|
|
// Set initial state from settings
|
|
const vis = (settings && settings.interfaceSettings && settings.interfaceSettings.cardVisibility) || {};
|
|
toggleCardTotalAssets.checked = vis.assets !== false;
|
|
toggleCardTotalComponents.checked = vis.components !== false;
|
|
toggleCardTotalValue.checked = vis.value !== false;
|
|
toggleCardWarrantiesTotal.checked = vis.warranties !== false;
|
|
toggleCardWarrantiesWithin60.checked = vis.within60 !== false;
|
|
toggleCardWarrantiesWithin30.checked = vis.within30 !== false;
|
|
toggleCardWarrantiesExpired.checked = vis.expired !== false;
|
|
toggleCardWarrantiesActive.checked = vis.active !== false;
|
|
}
|
|
|
|
// Add URL parameter handling for direct asset links
|
|
function handleUrlParameters() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const assetId = urlParams.get('ass');
|
|
const subAssetId = urlParams.get('sub');
|
|
|
|
console.log('handleUrlParameters called - URL:', window.location.href);
|
|
console.log('Parsed parameters - assetId:', assetId, 'subAssetId:', subAssetId);
|
|
|
|
if (assetId) {
|
|
console.log('URL parameter detected - navigating to asset:', assetId, subAssetId ? `sub-asset: ${subAssetId}` : '');
|
|
|
|
// Check if the asset exists
|
|
const targetAsset = assets.find(a => a.id === assetId);
|
|
const targetSubAsset = subAssetId ? subAssets.find(sa => sa.id === subAssetId) : null;
|
|
|
|
console.log('Found asset:', targetAsset ? targetAsset.name : 'NOT FOUND');
|
|
if (subAssetId) {
|
|
console.log('Found sub-asset:', targetSubAsset ? targetSubAsset.name : 'NOT FOUND');
|
|
}
|
|
|
|
if (!targetAsset) {
|
|
console.error('Asset not found for ID:', assetId);
|
|
globalThis.toaster?.show('Asset not found', 'error');
|
|
return false;
|
|
}
|
|
|
|
if (subAssetId && !targetSubAsset) {
|
|
console.error('Sub-asset not found for ID:', subAssetId);
|
|
globalThis.toaster?.show('Component not found', 'error');
|
|
return false;
|
|
}
|
|
|
|
// Clear URL parameters from browser history without page reload
|
|
if (window.history && window.history.replaceState) {
|
|
const cleanUrl = window.location.pathname;
|
|
window.history.replaceState({}, document.title, cleanUrl);
|
|
}
|
|
|
|
// Navigate to the asset/sub-asset
|
|
if (subAssetId) {
|
|
// Navigate to sub-asset
|
|
console.log('Navigating to sub-asset:', subAssetId);
|
|
updateSelectedIds(assetId, subAssetId);
|
|
renderAssetDetails(subAssetId, true);
|
|
} else {
|
|
// Navigate to main asset
|
|
console.log('Navigating to main asset:', assetId);
|
|
updateSelectedIds(assetId, null);
|
|
renderAssetDetails(assetId, false);
|
|
}
|
|
|
|
// Close sidebar on mobile after navigation
|
|
handleSidebarNav();
|
|
|
|
return true; // Indicates we handled URL parameters
|
|
}
|
|
|
|
console.log('No URL parameters found');
|
|
return false; // No URL parameters to handle
|
|
}
|
|
|
|
function goHome() {
|
|
// Clear selected asset
|
|
updateSelectedIds(null, null);
|
|
// Remove active class from all asset items
|
|
document.querySelectorAll('.asset-item').forEach(item => {
|
|
item.classList.remove('active');
|
|
});
|
|
// Render dashboard and charts
|
|
dashboardManager.renderDashboard();
|
|
// Close sidebar on mobile
|
|
handleSidebarNav();
|
|
}
|
|
|
|
// GLOBAL SHORTCUTS
|
|
function addShortcutEventListeners() {
|
|
// Add event listener for escape key to close all modals
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
[assetModal, subAssetModal, importModal, settingsModal].forEach(modal => {
|
|
if (modal && modal.style.display !== 'none') {
|
|
modal.style.display = 'none';
|
|
}
|
|
});
|
|
modalManager.closeAssetModal();
|
|
modalManager.closeSubAssetModal();
|
|
|
|
// Close duplicate modal if it exists
|
|
if (modalManager && modalManager.closeDuplicateModal) {
|
|
modalManager.closeDuplicateModal();
|
|
}
|
|
}
|
|
});
|
|
// Add click-off-to-close for all modals on overlay click
|
|
[importModal, settingsModal].forEach(modal => {
|
|
if (modal) {
|
|
modal.addEventListener('mousedown', function(e) {
|
|
if (e.target !== modal) return;
|
|
if (modal === settingsModal) {
|
|
settingsManager.closeSettingsModal();
|
|
}
|
|
else {
|
|
modal.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add click-off-to-close for duplicate modal
|
|
const duplicateModal = document.getElementById('duplicateModal');
|
|
if (duplicateModal) {
|
|
duplicateModal.addEventListener('mousedown', function(e) {
|
|
if (e.target !== duplicateModal) return;
|
|
if (modalManager && modalManager.closeDuplicateModal) {
|
|
modalManager.closeDuplicateModal();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function addElementEventListeners() {
|
|
// Add Ctrl+Enter keyboard shortcut to save the settings form
|
|
if (settingsModal && saveSettings) {
|
|
const settingsKeydownHandler = (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
saveSettings.click();
|
|
}
|
|
};
|
|
settingsModal.addEventListener('keydown', settingsKeydownHandler);
|
|
}
|
|
if (sidebarToggle) {
|
|
sidebarToggle.addEventListener('click', () => {
|
|
if (sidebar.classList.contains('open')) {
|
|
closeSidebar();
|
|
} else {
|
|
openSidebar();
|
|
}
|
|
});
|
|
}
|
|
if (sidebarCloseBtn) {
|
|
sidebarCloseBtn.addEventListener('click', () => {
|
|
closeSidebar();
|
|
});
|
|
}
|
|
// Close sidebar when clicking the overlay
|
|
if (sidebarOverlay) {
|
|
sidebarOverlay.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
closeSidebar();
|
|
});
|
|
}
|
|
// Set up search
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', (e) => {
|
|
renderAssetList(e.target.value);
|
|
if (clearSearchBtn) {
|
|
clearSearchBtn.style.display = e.target.value ? 'flex' : 'none';
|
|
}
|
|
});
|
|
}
|
|
if (clearSearchBtn && searchInput) {
|
|
clearSearchBtn.addEventListener('click', () => {
|
|
searchInput.value = '';
|
|
clearSearchBtn.style.display = 'none';
|
|
renderAssetList('');
|
|
searchInput.focus();
|
|
});
|
|
}
|
|
// Set up home button
|
|
if (homeBtn) {
|
|
homeBtn.addEventListener('click', () => goHome());
|
|
}
|
|
// Set up add asset button
|
|
if (addAssetBtn) {
|
|
addAssetBtn.addEventListener('click', () => {
|
|
modalManager.openAssetModal();
|
|
});
|
|
}
|
|
// Set up sort buttons
|
|
if (sortNameBtn) {
|
|
sortNameBtn.addEventListener('click', () => {
|
|
const currentDirection = sortNameBtn.getAttribute('data-direction') || 'asc';
|
|
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
|
|
|
|
// Update button state
|
|
sortNameBtn.setAttribute('data-direction', newDirection);
|
|
sortWarrantyBtn.setAttribute('data-direction', 'asc');
|
|
|
|
// Update sort settings
|
|
currentSort = { field: 'name', direction: newDirection };
|
|
updateSort(currentSort);
|
|
|
|
// Update UI
|
|
updateSortButtons(sortNameBtn);
|
|
|
|
// Re-render with sort
|
|
renderAssetList(searchInput ? searchInput.value : '');
|
|
});
|
|
}
|
|
if (sortWarrantyBtn) {
|
|
sortWarrantyBtn.addEventListener('click', () => {
|
|
const currentDirection = sortWarrantyBtn.getAttribute('data-direction') || 'asc';
|
|
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
|
|
|
|
// Update button state
|
|
sortWarrantyBtn.setAttribute('data-direction', newDirection);
|
|
sortNameBtn.setAttribute('data-direction', 'asc');
|
|
|
|
// Update sort settings
|
|
currentSort = { field: 'warranty', direction: newDirection };
|
|
updateSort(currentSort);
|
|
|
|
// Update UI
|
|
updateSortButtons(sortWarrantyBtn);
|
|
|
|
// Re-render with sort
|
|
renderAssetList(searchInput ? searchInput.value : '');
|
|
});
|
|
}
|
|
// Top Sort Button
|
|
if (topSortBtn) {
|
|
topSortBtn.addEventListener('click', () => {
|
|
const sortOptions = document.getElementById('sortOptions');
|
|
if (sortOptions) {
|
|
sortOptions.classList.toggle('visible');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Set the page and site title from config if available
|
|
function setupPageTitle() {
|
|
if (window.appConfig && window.appConfig.siteTitle) {
|
|
if (siteTitleElem) {
|
|
siteTitleElem.textContent = window.appConfig.siteTitle || 'DumbAssets';
|
|
}
|
|
if (pageTitleElem) {
|
|
pageTitleElem.textContent = window.appConfig.siteTitle || 'DumbAssets';
|
|
}
|
|
siteTitleElem.addEventListener('click', () => goHome());
|
|
}
|
|
}
|
|
}); |