coderabbitai[bot] ce4de0c8ad
📝 Add docstrings to integration-manager-with-paperless
Docstrings generation was requested by @abiteman.

* https://github.com/DumbWareio/DumbAssets/pull/112#issuecomment-3082310774

The following files were modified:

* `public/script.js`
* `server.js`
* `src/services/render/assetRenderer.js`
* `src/services/render/previewRenderer.js`
2025-07-17 03:25:34 +00:00

1964 lines
87 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,
initPreviewRenderer
} 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';
import { ExternalDocManager } from './managers/externalDocManager.js';
import { IntegrationsManager } from './managers/integrations.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;
let externalDocManager;
let integrationsManager;
const chartManager = new ChartManager({ formatDate });
// Acts as constructor for the app
/**
* Initializes the DumbAssets application, setting up integrations, managers, UI components, event listeners, and loading initial data.
*
* This function bootstraps the entire app, including loading integrations for dynamic features, initializing all core managers and modules, configuring UI event handlers, and rendering the initial dashboard or asset view based on URL parameters.
*/
async function initialize() {
// Display demo banner if in demo mode
if (window.appConfig?.demoMode) {
document.getElementById('demo-banner').style.display = 'block';
}
// Initialize integrations manager and load integration data early for dynamic badge generation
integrationsManager = new IntegrationsManager({
setButtonLoading
});
try {
await integrationsManager.loadIntegrations();
// Initialize preview renderer with integrations manager
initPreviewRenderer({
integrationsManager
});
} catch (error) {
console.warn('Failed to load integrations for badge generation:', error);
}
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,
// Managers
integrationsManager
});
// 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,
// Integrations
integrationsManager
});
// Initialize ExternalDocManager
externalDocManager = new ExternalDocManager({
modalManager,
setButtonLoading,
integrationsManager
});
// 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),
loadActiveIntegrations: () => externalDocManager.loadActiveIntegrations(),
integrationsManager
});
}
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());
}
}
});