Merge pull request #43 from DumbWareio/dev

Final features (#42)
This commit is contained in:
Chris 2025-06-03 18:18:05 -07:00 committed by GitHub
commit dcff8c525d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 711 additions and 377 deletions

View File

@ -1,6 +1,8 @@
# DumbAssets
A stupid simple asset tracker for keeping track of your physical assets and their components.
A stupid simple asset tracker for keeping track of your physical assets, their components, and applicable warranties and routine maintenance.
[Demo](https://dumbassets.dumbware.io)
---
@ -89,29 +91,13 @@ Open your browser to [http://localhost:3000](http://localhost:3000)
- 🔍 Search by name, model, serial, or description
- 🏷️ Hierarchical organization of components
- 📅 Warranty expiration notifications (configurable)
- 🔧 Maintenance event notifications
- 🏷️ Flexible tagging system for better organization
- 🔔 Apprise notification integration
- 🌗 Light/Dark mode with theme persistence
- 🛡️ PIN authentication with brute force protection
- 📦 Docker support for easy deployment
- **Direct Asset Linking**: Notifications now include clickable links that directly open the specific asset in your browser
## Direct Asset Linking
When you receive notifications (warranty expiring, asset added/edited, maintenance due), they now include direct links to view the specific asset. Simply click the "🔗 View Asset" link in the notification to be taken directly to that asset's details page.
### URL Format
- Main assets: `yoursite.com?ass=asset-id`
- Sub-assets: `yoursite.com?ass=parent-id&sub=sub-asset-id`
### Configuration
To use this feature, set the `BASE_URL` environment variable to your domain:
```bash
BASE_URL=https://assets.yourcompany.com
```
If not set, it defaults to `http://localhost:3000`.
---
- 🔗 Direct Asset Linking: Notifications include links to the specific asset
## Configuration
@ -126,6 +112,7 @@ If not set, it defaults to `http://localhost:3000`.
| BASE_URL | Base URL for the application | http://localhost | No |
| SITE_TITLE | Site title shown in browser tab and header | DumbAssets | No |
| ALLOWED_ORIGINS | Origins allowed to visit your instance | '*' | No |
| DEMO_MODE | Enables read-only mode | false | No |
### Data Storage
@ -152,12 +139,31 @@ All data is stored in JSON files in the `/data` directory:
## Technical Details
### Stack
- **Backend:** Node.js (>=14.0.0) with Express
- **Frontend:** Vanilla JavaScript (ES6+)
- **Container:** Docker with Alpine base
- **Notifications:** Apprise integration (via Python)
- **Uploads:** Multer for file handling
- **Scheduling:** node-cron for warranty notifications
- **Scheduling:** node-cron for warranty & Maintenance notifications
### Dependencies
- **express**: Web framework for Node.js
- **multer**: File upload handling and multipart/form-data parsing
- **apprise**: Notification system integration for alerts
- **cors**: Cross-origin resource sharing middleware
- **dotenv**: Environment variable configuration management
- **express-rate-limit**: Rate limiting middleware for API protection
- **express-session**: Session management and authentication
- **cookie-parser**: Cookie parsing middleware
- **node-cron**: Task scheduling for notifications
- **uuid**: Unique ID generation for assets
- **sharp**: Image processing and optimization
- **compression**: Response compression middleware
- **helmet**: Security headers middleware
- **fs-extra**: Enhanced filesystem operations
- **path**: Path manipulation utilities
---
@ -173,4 +179,4 @@ See the Development Guide for local setup and guidelines.
---
Made with ❤️ by DumbWare.io
Made with ❤️ by [DumbWare.io](https://dumbware.io)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1 +0,0 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="700" height="700" viewBox="326,85.5,700,700"><defs><clipPath id="clip-1"><rect x="426" y="185.5" width="500" height="500" id="clip-1" fill="none" stroke-width="1"/></clipPath><clipPath id="clip-2"><rect x="22.28453" y="-63.09825" transform="rotate(90) scale(12.93586,12.93586)" width="24" height="24" id="clip-2" fill="none"/></clipPath></defs><g id="document" fill="#ffffff" fill-rule="nonzero" stroke="#000000" stroke-width="0" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><rect x="326" y="61.07143" transform="scale(1,1.4)" width="700" height="500" id="Shape 1 1" vector-effect="non-scaling-stroke"/></g><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="none" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="stage"><g id="layer1 1"><g clip-path="url(#clip-1)" id="Group 1"><circle cx="688.579" cy="444.165" r="219.498" id="Shape 1" fill="#fd96a9" stroke-width="1" opacity="0.2"/><path d="M673.527,194.641c-133.189,0 -241.476,108.289 -241.476,241.478c0,133.189 108.287,241.476 241.476,241.476c133.19,0 241.476,-108.287 241.476,-241.476c0,-133.189 -108.286,-241.478 -241.476,-241.478M673.527,633.827c-108.947,0 -197.614,-88.666 -197.614,-197.614c0,-108.947 88.667,-197.616 197.614,-197.616c108.947,0 197.614,88.575 197.614,197.616c0,108.948 -88.667,197.614 -197.614,197.614" id="CompoundPath 1" fill="#fd96a9" stroke-width="1"/><path d="" id="Path 1" fill="#ff4b91" stroke-width="4"/><path d="M117.65819,483.58962l-18.40732,-12.23549l92.77574,-132.16827l84.96726,-14.982l3.87044,21.95039l-75.62735,13.33512zM234.4318,603.29473l-116.53934,-81.6009l100.8393,-141.95213l98.9279,-17.44364l17.17848,97.4241zM265.8098,436.36088c8.82993,6.18365 20.97568,4.01969 27.16106,-4.80039c6.18266,-8.82975 4.04037,-20.97933 -4.78956,-27.16297c-8.83074,-6.18249 -20.98031,-4.04019 -27.16297,4.78956c-6.18347,8.83091 -4.03736,21.00215 4.79147,27.17381zM199.20946,498.13235l31.9765,-45.64004l-9.12649,-6.3913l-31.97731,45.64119zM204.68233,529.17054l44.75859,-63.89597l-9.12747,-6.39113l-44.75859,63.89597zM222.96068,541.95883l44.73519,-63.902l-9.12747,-6.39113l-44.73519,63.902z" id="CompoundPath 1" fill="#fd96a9" stroke-width="1"/></g><g clip-path="url(#clip-2)" id="Group 1" stroke-width="1"><path d="M537.28139,438.35153l-18.5759,-17.69626l132.6961,-132.38564h100.14947v25.87173h-89.14104zM646.64119,598.73038l-116.77205,-116.77205l143.88562,-141.94524h116.60389v114.83167zM716.15853,414.22614c8.84813,8.84813 23.16813,8.82226 32.01627,-0.01294c8.84813,-8.84813 8.84813,-23.16813 0,-32.01627c-8.84813,-8.84813 -23.16813,-8.84813 -32.01627,0c-8.84813,8.84813 -8.84813,23.19401 0,32.0292zM627.57373,471.4156l45.75415,-45.72828l-9.14566,-9.14566l-45.75415,45.72828zM627.57373,507.99822l64.04547,-64.01959l-9.14566,-9.14566l-64.04547,64.01959zM645.89091,526.30247l64.01959,-64.03253l-9.14566,-9.14566l-64.01959,64.03253z" id="CompoundPath 1" fill="#fd96a9"/></g></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -12,6 +12,6 @@ function demoModeMiddleware(req, res, next) {
next();
}
export {
module.exports = {
demoModeMiddleware,
}

197
package-lock.json generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,30 +1 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.2;fill:#CE6746;enable-background:new ;}
.st1{fill:#CE6746;}
.st2{display:none;fill:#50AD3D;}
.st3{display:none;fill:#5F99FF;}
</style>
<circle class="st0" cx="256" cy="256" r="232.7"/>
<path class="st1" d="M256,0C114.8,0,0,114.8,0,256s114.8,256,256,256s256-114.8,256-256S397.2,0,256,0z M256,465.6
c-115.5,0-209.5-94-209.5-209.5S140.5,46.6,256,46.6s209.5,93.9,209.5,209.5C465.5,371.6,371.5,465.6,256,465.6z"/>
<path class="st2" d="M281.1,240.4c-11.2-4.7-52.5-11.2-52.5-29.3c0-13.3,15.4-17.2,24.7-17.2c8.4,0,18.5,2.7,23.9,8
c3.4,3.2,4.8,5.6,5.9,8.9c1.4,3.8,2.3,8,7.6,8h27.5c6.5,0,8.1-1.2,8.1-8.3c0-30.4-23.9-47.6-51.1-53.5v-23.7c0-5.3-1.7-8.6-8.1-8.6
h-21.9c-6.5,0-8.1,3.2-8.1,8.6V156c-29.2,5.6-53.6,23.9-53.6,57.1c0,37.5,26.1,49.4,54.4,58.8c23.9,8,48.8,8.3,48.8,24.8
c0,16.6-12.3,20.1-27.5,20.1c-10.4,0-21.9-2.7-27.5-9.8c-3.6-4.4-5-8.3-5.3-12.1c-0.6-7.4-3.6-8.3-10.1-8.3h-27.2
c-6.5,0-8.1,1.5-8.1,8.3c0,33.7,27,54.1,56.1,60.6v23.1c0,5.3,1.7,8.6,8.1,8.6h21.9c6.5,0,8.1-3.3,8.1-8.6v-22.4
c33.1-5,55.8-25.1,55.8-60.6C331.1,255.4,301.4,246.8,281.1,240.4"/>
<path class="st1" d="M259.9,271.2l-103,67.4c-8.8,5.8-20.2,2.4-25.4-7.7c-5.2-10.1-2-22.9,6.8-28.7l74.8-49l-77.3-45.8
c-9.1-5.4-12.6-18.1-7.9-28.4c4.7-10.3,16-14.3,25-8.9l105.5,62.4c3.9,3.1,7.1,6.4,8.7,11C271.6,253.3,268.5,265.6,259.9,271.2z
M386.2,328L386.2,328c0-9-7.3-16.4-16.4-16.4h-86.8c-9,0-16.4,7.3-16.4,16.4v0c0,9,7.3,16.4,16.4,16.4h86.8
C378.9,344.3,386.2,337,386.2,328z"/>
<path class="st3" d="M354.3,147.4h-18.5v-7.1c0-4.2-3.4-7.6-7.6-7.6h-7.8c-4.2,0-7.6,3.4-7.6,7.6v7.1l0,0H199.2v-7.1
c0-4.2-3.4-7.6-7.6-7.6h-7.8c-4.2,0-7.6,3.4-7.6,7.6v7.1h-18.5c-17.6,0-31.9,14.3-31.9,31.9v25.6v142.5c0,17.6,14.3,31.9,31.9,31.9
h196.5c17.6,0,31.9-14.3,31.9-31.9V204.9v-25.6C386.1,161.7,371.8,147.4,354.3,147.4z M143.2,179.3c0-8,6.5-14.5,14.5-14.5h18.5v7.1
c0,4.2,3.4,7.6,7.6,7.6h7.8c4.2,0,7.6-3.4,7.6-7.6v-7.1h113.7l0,0v7.1c0,4.2,3.4,7.6,7.6,7.6h7.8c4.2,0,7.6-3.4,7.6-7.6v-7.1h18.5
c8,0,14.5,6.5,14.5,14.5v17H143.2V179.3z M368.8,347.4c0,8-6.5,14.5-14.5,14.5H157.7c-8,0-14.5-6.5-14.5-14.5V213.6h225.5
L368.8,347.4L368.8,347.4z"/>
</svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="700" height="700" viewBox="410,175,520,520"><defs><clipPath id="clip-1"><rect x="426" y="185.5" width="500" height="500" id="clip-1" fill="none" stroke-width="1"/></clipPath><clipPath id="clip-2"><rect x="22.28453" y="-63.09825" transform="rotate(90) scale(12.93586,12.93586)" width="24" height="24" id="clip-2" fill="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="none" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="stage"><g id="layer1 1"><g clip-path="url(#clip-1)" id="Group 1"><circle cx="688.579" cy="444.165" r="219.498" id="Shape 1" fill="#fd96a9" stroke-width="1" opacity="0.2"/><path d="M673.527,194.641c-133.189,0 -241.476,108.289 -241.476,241.478c0,133.189 108.287,241.476 241.476,241.476c133.19,0 241.476,-108.287 241.476,-241.476c0,-133.189 -108.286,-241.478 -241.476,-241.478M673.527,633.827c-108.947,0 -197.614,-88.666 -197.614,-197.614c0,-108.947 88.667,-197.616 197.614,-197.616c108.947,0 197.614,88.575 197.614,197.616c0,108.948 -88.667,197.614 -197.614,197.614" id="CompoundPath 1" fill="#fd96a9" stroke-width="1"/><path d="" id="Path 1" fill="#ff4b91" stroke-width="4"/><path d="M117.65819,483.58962l-18.40732,-12.23549l92.77574,-132.16827l84.96726,-14.982l3.87044,21.95039l-75.62735,13.33512zM234.4318,603.29473l-116.53934,-81.6009l100.8393,-141.95213l98.9279,-17.44364l17.17848,97.4241zM265.8098,436.36088c8.82993,6.18365 20.97568,4.01969 27.16106,-4.80039c6.18266,-8.82975 4.04037,-20.97933 -4.78956,-27.16297c-8.83074,-6.18249 -20.98031,-4.04019 -27.16297,4.78956c-6.18347,8.83091 -4.03736,21.00215 4.79147,27.17381zM199.20946,498.13235l31.9765,-45.64004l-9.12649,-6.3913l-31.97731,45.64119zM204.68233,529.17054l44.75859,-63.89597l-9.12747,-6.39113l-44.75859,63.89597zM222.96068,541.95883l44.73519,-63.902l-9.12747,-6.39113l-44.73519,63.902z" id="CompoundPath 1" fill="#fd96a9" stroke-width="1"/></g><g clip-path="url(#clip-2)" id="Group 1" stroke-width="1"><path d="M537.28139,438.35153l-18.5759,-17.69626l132.6961,-132.38564h100.14947v25.87173h-89.14104zM646.64119,598.73038l-116.77205,-116.77205l143.88562,-141.94524h116.60389v114.83167zM716.15853,414.22614c8.84813,8.84813 23.16813,8.82226 32.01627,-0.01294c8.84813,-8.84813 8.84813,-23.16813 0,-32.01627c-8.84813,-8.84813 -23.16813,-8.84813 -32.01627,0c-8.84813,8.84813 -8.84813,23.19401 0,32.0292zM627.57373,471.4156l45.75415,-45.72828l-9.14566,-9.14566l-45.75415,45.72828zM627.57373,507.99822l64.04547,-64.01959l-9.14566,-9.14566l-64.04547,64.01959zM645.89091,526.30247l64.01959,-64.03253l-9.14566,-9.14566l-64.01959,64.03253z" id="CompoundPath 1" fill="#fd96a9"/></g></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -588,6 +588,7 @@
<div class="tab-nav">
<button class="tab-btn active" data-tab="notifications">Notifications</button>
<button class="tab-btn" data-tab="interface">Interface</button>
<button class="tab-btn" data-tab="system">System</button>
<!-- Add more tab buttons here in the future -->
</div>
<div class="tab-content">
@ -783,25 +784,38 @@
</form>
</div>
<!-- Add more tab panes here in the future -->
<!-- Example for a future tab -->
<!-- <div class="tab-pane" id="general-tab">
<form id="generalSettingsForm">
<!-- System Settings Tab -->
<div class="tab-pane" id="system-tab">
<form id="systemSettingsForm">
<fieldset>
<legend>Application</legend>
<div class="settings-grid">
<div class="toggle-row">
<span>Auto-refresh Assets</span>
<label class="toggle-switch">
<input type="checkbox" name="autoRefreshAssets" id="autoRefreshAssets">
<span class="slider"></span>
</label>
<legend>Data Export</legend>
<div class="export-section">
<div>
<button type="button" id="exportDataBtn" class="action-button export-btn">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-15"></path>
<path d="M7 10l5 5 5-5"></path>
<path d="M12 15V3"></path>
</svg>
Export All Data CSV
<div class="spinner"></div>
</button>
</div>
<div>
<button type="button" id="exportSimpleDataBtn" class="action-button export-btn">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-15"></path>
<path d="M7 10l5 5 5-5"></path>
<path d="M12 15V3"></path>
</svg>
Export Simple CSV
<div class="spinner"></div>
</button>
</div>
</div>
</fieldset>
</form>
</div> -->
</div>
</div>
<!-- Settings modal actions - now outside of individual forms -->

View File

@ -309,7 +309,14 @@ export class DashboardManager {
} else {
this.updateDashboardFilter(filter);
}
this.renderAssetList(this.searchInput.value);
// Update events display if events section exists
const eventsTable = document.getElementById('eventsTable');
if (eventsTable) {
this.updateEventsDisplay();
}
});
});
}
@ -624,7 +631,7 @@ export class DashboardManager {
eventsTableContainer.addEventListener('click', (e) => this.handlePaginationClick(e));
}
// Initialize the display with click handlers
// Initialize the display with click handlers and apply any existing filters
this.updateEventsDisplay();
}
@ -671,11 +678,90 @@ export class DashboardManager {
updateEventsDisplay() {
let events = this.collectUpcomingEvents();
// Apply filter
// Apply local events filter (all, warranty, maintenance)
if (this.currentFilter !== 'all') {
events = events.filter(event => event.type === this.currentFilter);
}
// Apply global dashboard filter to respect the same filtering as asset list
const dashboardFilter = this.getDashboardFilter();
if (dashboardFilter) {
const now = new Date();
if (dashboardFilter === 'components') {
// Only show events from sub-assets (components)
events = events.filter(event => event.isSubAsset);
}
else if (dashboardFilter === 'warranties') {
// Only show warranty events
events = events.filter(event => event.type === 'warranty');
}
else if (dashboardFilter === 'expired') {
// Only show events with expired warranties or overdue maintenance
events = events.filter(event => {
if (event.type === 'warranty') {
return event.date < now;
} else if (event.type === 'maintenance') {
return event.date < now; // Overdue maintenance
}
return false;
});
}
else if (dashboardFilter === 'within30') {
// Only show events within 30 days
events = events.filter(event => {
const diff = (event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
return diff >= 0 && diff <= 30;
});
}
else if (dashboardFilter === 'within60') {
// Only show events within 31-60 days
events = events.filter(event => {
const diff = (event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
return diff > 30 && diff <= 60;
});
}
else if (dashboardFilter === 'active') {
// Only show events from items with active warranties (more than 60 days or lifetime)
// For warranty events: show those more than 60 days away
// For maintenance events: show all from assets/components with active warranties
events = events.filter(event => {
if (event.type === 'warranty') {
const diff = (event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
return diff > 60;
} else if (event.type === 'maintenance') {
// For maintenance events, check if the parent asset/component has an active warranty
const assets = this.getAssets();
const subAssets = this.getSubAssets();
if (event.isSubAsset) {
const subAsset = subAssets.find(sa => sa.id === event.id);
if (subAsset && subAsset.warranty) {
if (subAsset.warranty.isLifetime) return true;
if (subAsset.warranty.expirationDate) {
const expDate = new Date(subAsset.warranty.expirationDate);
const diff = (expDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
return diff > 60;
}
}
} else {
const asset = assets.find(a => a.id === event.id);
if (asset && asset.warranty) {
if (asset.warranty.isLifetime) return true;
if (asset.warranty.expirationDate) {
const expDate = new Date(asset.warranty.expirationDate);
const diff = (expDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
return diff > 60;
}
}
}
return false;
}
return false;
});
}
}
// Apply sort
if (this.currentSort.direction === 'desc') {
events.reverse();
@ -825,4 +911,15 @@ export class DashboardManager {
});
}
}
/**
* Public method to refresh events display - useful for external calls
*/
refreshEventsDisplay() {
// Only refresh if events section exists
const eventsTable = document.getElementById('eventsTable');
if (eventsTable) {
this.updateEventsDisplay();
}
}
}

View File

@ -526,16 +526,6 @@ export class ModalManager {
manualInfo.originalName || asset.manualPath.split('/').pop(),
manualInfo.size ? this.formatFileSize(manualInfo.size) : 'Unknown size'
);
const deleteBtn = manualPreview.querySelector('.delete-preview-btn');
if (deleteBtn) {
deleteBtn.onclick = () => {
if (confirm('Are you sure you want to delete this manual?')) {
manualPreview.innerHTML = '';
if (manualInput) manualInput.value = '';
this.deleteManual = true;
}
};
}
containsExistingFiles = true;
}
@ -593,16 +583,6 @@ export class ModalManager {
manualInfo.originalName || subAsset.manualPath.split('/').pop(),
manualInfo.size ? this.formatFileSize(manualInfo.size) : 'Unknown size'
);
const deleteBtn = manualPreview.querySelector('.delete-preview-btn');
if (deleteBtn) {
deleteBtn.onclick = () => {
if (confirm('Are you sure you want to delete this manual?')) {
manualPreview.innerHTML = '';
if (manualInput) manualInput.value = '';
this.deleteSubManual = true;
}
};
}
containsExistingFiles = true;
}

View File

@ -70,6 +70,19 @@ export class SettingsManager {
this.cancelSettings.addEventListener('click', () => this.closeSettingsModal());
this.saveSettings.addEventListener('click', () => this._saveSettings());
this.testNotificationSettings.addEventListener('click', () => this._testNotificationSettings());
// Export button
const exportDataBtn = document.getElementById('exportDataBtn');
if (exportDataBtn) {
exportDataBtn.addEventListener('click', () => this._exportData());
}
// Export simple data button
const exportSimpleDataBtn = document.getElementById('exportSimpleDataBtn');
if (exportSimpleDataBtn) {
exportSimpleDataBtn.addEventListener('click', () => this._exportSimpleData());
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tabId = btn.getAttribute('data-tab');
@ -500,4 +513,309 @@ export class SettingsManager {
}
}
}
async _exportData() {
const exportBtn = document.getElementById('exportDataBtn');
if (!exportBtn) return;
this.setButtonLoading(exportBtn, true);
try {
const apiBaseUrl = globalThis.getApiBaseUrl();
// Fetch both assets and sub-assets
const [assetsResponse, subAssetsResponse] = await Promise.all([
fetch(`${apiBaseUrl}/api/assets`, { credentials: 'include' }),
fetch(`${apiBaseUrl}/api/subassets`, { credentials: 'include' })
]);
// Validate responses
const assetsValidation = await globalThis.validateResponse(assetsResponse);
if (assetsValidation.errorMessage) throw new Error(assetsValidation.errorMessage);
const subAssetsValidation = await globalThis.validateResponse(subAssetsResponse);
if (subAssetsValidation.errorMessage) throw new Error(subAssetsValidation.errorMessage);
const assets = await assetsResponse.json();
const subAssets = await subAssetsResponse.json();
// Generate CSV content
const csvContent = this._generateCSV(assets, subAssets);
// Create and download the file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Generate filename with current date
const now = new Date();
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD format
const filename = `dumbAssets_export_${dateStr}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
globalThis.toaster.show('Data exported successfully!');
} catch (error) {
globalThis.logError('Failed to export data:', error.message);
} finally {
this.setButtonLoading(exportBtn, false);
}
}
_generateCSV(assets, subAssets) {
// CSV headers
const headers = [
'Type',
'ID',
'Name',
'Manufacturer',
'Model Number',
'Serial Number',
'Purchase Date',
'Purchase Price',
'Currency',
'Location',
'URL',
'Notes',
'Tags',
'Warranty Scope',
'Warranty Expiration',
'Warranty Lifetime',
'Secondary Warranty Scope',
'Secondary Warranty Expiration',
'Secondary Warranty Lifetime',
'Maintenance Events',
'Photo Path',
'Receipt Path',
'Manual Path',
'Parent ID',
'Parent Sub ID',
'Created At',
'Updated At'
];
const rows = [headers];
// Helper function to escape CSV values
const escapeCsvValue = (value) => {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
// Helper function to format maintenance events
const formatMaintenanceEvents = (events) => {
if (!events || !events.length) return '';
return events.map(event => {
let eventStr = `${event.name}`;
if (event.type === 'frequency') {
eventStr += ` (Every ${event.frequency} ${event.frequencyUnit})`;
if (event.nextDueDate) {
eventStr += ` - Next: ${event.nextDueDate}`;
}
} else if (event.type === 'specific' && event.specificDate) {
eventStr += ` - Date: ${event.specificDate}`;
}
if (event.notes) {
eventStr += ` - Notes: ${event.notes}`;
}
return eventStr;
}).join('; ');
};
// Add assets
assets.forEach(asset => {
const row = [
'Asset',
asset.id || '',
asset.name || '',
asset.manufacturer || '',
asset.modelNumber || '',
asset.serialNumber || '',
asset.purchaseDate || '',
asset.price || '',
asset.currency || '',
asset.location || '',
asset.url || '',
asset.notes || '',
(asset.tags && asset.tags.length > 0) ? asset.tags.join('; ') : '',
asset.warranty?.scope || '',
asset.warranty?.expirationDate || '',
asset.warranty?.isLifetime ? 'Yes' : 'No',
asset.secondaryWarranty?.scope || '',
asset.secondaryWarranty?.expirationDate || '',
asset.secondaryWarranty?.isLifetime ? 'Yes' : 'No',
formatMaintenanceEvents(asset.maintenanceEvents),
asset.photoPath || '',
asset.receiptPath || '',
asset.manualPath || '',
'', // Parent ID (empty for assets)
'', // Parent Sub ID (empty for assets)
asset.createdAt || '',
asset.updatedAt || ''
];
rows.push(row.map(escapeCsvValue));
});
// Add sub-assets
subAssets.forEach(subAsset => {
const row = [
subAsset.parentSubId ? 'Sub-Component' : 'Component',
subAsset.id || '',
subAsset.name || '',
subAsset.manufacturer || '',
subAsset.modelNumber || '',
subAsset.serialNumber || '',
subAsset.purchaseDate || '',
subAsset.purchasePrice || '',
subAsset.currency || '',
subAsset.location || '',
subAsset.url || '',
subAsset.notes || '',
(subAsset.tags && subAsset.tags.length > 0) ? subAsset.tags.join('; ') : '',
subAsset.warranty?.scope || '',
subAsset.warranty?.expirationDate || '',
subAsset.warranty?.isLifetime ? 'Yes' : 'No',
'', // Secondary warranty scope (sub-assets don't have secondary warranties)
'', // Secondary warranty expiration
'', // Secondary warranty lifetime
formatMaintenanceEvents(subAsset.maintenanceEvents),
subAsset.photoPath || '',
subAsset.receiptPath || '',
subAsset.manualPath || '',
subAsset.parentId || '',
subAsset.parentSubId || '',
subAsset.createdAt || '',
subAsset.updatedAt || ''
];
rows.push(row.map(escapeCsvValue));
});
// Convert to CSV string
return rows.map(row => row.join(',')).join('\n');
}
async _exportSimpleData() {
const exportBtn = document.getElementById('exportSimpleDataBtn');
if (!exportBtn) return;
this.setButtonLoading(exportBtn, true);
try {
const apiBaseUrl = globalThis.getApiBaseUrl();
// Fetch both assets and sub-assets
const [assetsResponse, subAssetsResponse] = await Promise.all([
fetch(`${apiBaseUrl}/api/assets`, { credentials: 'include' }),
fetch(`${apiBaseUrl}/api/subassets`, { credentials: 'include' })
]);
// Validate responses
const assetsValidation = await globalThis.validateResponse(assetsResponse);
if (assetsValidation.errorMessage) throw new Error(assetsValidation.errorMessage);
const subAssetsValidation = await globalThis.validateResponse(subAssetsResponse);
if (subAssetsValidation.errorMessage) throw new Error(subAssetsValidation.errorMessage);
const assets = await assetsResponse.json();
const subAssets = await subAssetsResponse.json();
// Generate simplified CSV content
const csvContent = this._generateSimpleCSV(assets, subAssets);
// Create and download the file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Generate filename with current date
const now = new Date();
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD format
const filename = `dumbAssets_simple_export_${dateStr}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
globalThis.toaster.show('Simple data exported successfully!');
} catch (error) {
globalThis.logError('Failed to export simple data:', error.message);
} finally {
this.setButtonLoading(exportBtn, false);
}
}
_generateSimpleCSV(assets, subAssets) {
// Simple CSV headers - only basic fields
const headers = [
'Name',
'Manufacturer',
'Model',
'Serial',
'Purchase Date',
'Purchase Price',
'Notes',
'URL'
];
const rows = [headers];
// Helper function to escape CSV values
const escapeCsvValue = (value) => {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
// Add assets
assets.forEach(asset => {
const row = [
asset.name || '',
asset.manufacturer || '',
asset.modelNumber || '',
asset.serialNumber || '',
asset.purchaseDate || '',
asset.price || '',
asset.notes || '',
asset.url || ''
];
rows.push(row.map(escapeCsvValue));
});
// Add sub-assets
subAssets.forEach(subAsset => {
const row = [
subAsset.name || '',
subAsset.manufacturer || '',
subAsset.modelNumber || '',
subAsset.serialNumber || '',
subAsset.purchaseDate || '',
subAsset.purchasePrice || '',
subAsset.notes || '',
subAsset.url || ''
];
rows.push(row.map(escapeCsvValue));
});
// Convert to CSV string
return rows.map(row => row.join(',')).join('\n');
}
}

View File

@ -23,7 +23,7 @@ import {
// Import list renderer functions
initListRenderer,
updateListState,
updateDashboardFilter,
updateDashboardFilter as updateListDashboardFilter,
updateSort,
renderAssetList,
sortAssets,
@ -53,6 +53,12 @@ document.addEventListener('DOMContentLoaded', () => {
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');
@ -387,11 +393,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Also add a dedicated refresh function to reload data without resetting the UI
async function refreshAllData() {
try {
const responses = await Promise.all([loadAssets(), loadSubAssets()]);
responses.forEach(async (response) => {
const responseValidation = await globalThis.validateResponse(response);
if (responseValidation.errorMessage) throw new Error(responseValidation.errorMessage);
})
const loadPromises = [loadAssets(), loadSubAssets()];
await Promise.all(loadPromises);
return true;
} catch (error) {
globalThis.logError('Error refreshing data:', error.message);
@ -401,9 +404,9 @@ document.addEventListener('DOMContentLoaded', () => {
async function saveAsset(asset) {
const saveBtn = assetForm.querySelector('.save-btn');
setButtonLoading(saveBtn, true);
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;
@ -420,46 +423,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
console.log('Edit mode determined as:', isEditMode);
// Log the current state of delete flags
console.log('Current delete flags:', {
deletePhoto: window.deletePhoto,
deleteReceipt: window.deleteReceipt,
deleteManual: window.deleteManual
});
// Handle file deletions
if (deletePhoto && assetToSave.photoPath) {
console.log(`Deleting photo: ${assetToSave.photoPath}`);
await fetch(`${apiBaseUrl}/api/delete-file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: assetToSave.photoPath }),
credentials: 'include'
});
assetToSave.photoPath = null;
}
if (deleteReceipt && assetToSave.receiptPath) {
console.log(`Deleting receipt: ${assetToSave.receiptPath}`);
await fetch(`${apiBaseUrl}/api/delete-file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: assetToSave.receiptPath }),
credentials: 'include'
});
assetToSave.receiptPath = null;
}
if (deleteManual && assetToSave.manualPath) {
console.log(`Deleting manual: ${assetToSave.manualPath}`);
await fetch(`${apiBaseUrl}/api/delete-file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: assetToSave.manualPath }),
credentials: 'include'
});
assetToSave.manualPath = null;
}
console.log('After handling deletions, asset state:', {
photoPath: assetToSave.photoPath,
@ -531,9 +494,9 @@ document.addEventListener('DOMContentLoaded', () => {
async function saveSubAsset(subAsset) {
const saveBtn = subAssetForm.querySelector('.save-btn');
setButtonLoading(saveBtn, true);
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;
@ -557,34 +520,6 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Missing required fields for sub-asset. Check the console for details.');
}
if (deleteSubPhoto && subAsset.photoPath) {
await fetch(`${apiBaseUrl}/api/delete-file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: subAsset.photoPath }),
credentials: 'include'
});
subAsset.photoPath = null;
}
if (deleteSubReceipt && subAsset.receiptPath) {
await fetch(`${apiBaseUrl}/api/delete-file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: subAsset.receiptPath }),
credentials: 'include'
});
subAsset.receiptPath = null;
}
if (deleteSubManual && subAsset.manualPath) {
await fetch(`${apiBaseUrl}/api/delete-file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: subAsset.manualPath }),
credentials: 'include'
});
subAsset.manualPath = null;
}
const response = await fetch(`${apiBaseUrl}/api/subasset`, {
method: isEditMode ? 'PUT' : 'POST',
headers: {
@ -616,7 +551,7 @@ document.addEventListener('DOMContentLoaded', () => {
refreshAssetDetails(subAsset.id, true);
} else {
// Navigate based on the saved component's context
await handleComponentNavigation(savedSubAsset);
await handleComponentNavigation({ id: savedSubAsset.id, parentId: savedSubAsset.parentId, parentSubId: savedSubAsset.parentSubId }, true);
}
// Show success message

View File

@ -363,7 +363,6 @@ input[type="date"][data-has-value="true"] {
justify-content: center;
border-radius: 50%;
transition: background-color var(--transition);
}
.header-btn:hover, #sidebarToggle:hover, #themeToggle:hover {
@ -481,7 +480,7 @@ input[type="date"][data-has-value="true"] {
border-radius: var(--app-border-radius);
overflow: hidden;
flex: 1;
min-height: calc(100vh - 7.5rem);
min-height: calc(100vh - 7rem);
max-height: calc(100vh - 7rem);
height: calc(100vh - 7rem); /* Explicit height */
}
@ -1373,10 +1372,83 @@ input[type="date"][data-has-value="true"] {
transform: translateX(18px);
}
#settingsModal .modal-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
gap: 10px;
margin-top: 1.2em;
}
/* Export Section Styling */
#settingsModal .export-section {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
/* Make export sections display side by side */
#systemSettingsForm .settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 0;
padding: 0;
}
#settingsModal .export-btn {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--success-color);
color: white;
border: none;
border-radius: var(--app-border-radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
#settingsModal .export-btn:hover {
background: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
#settingsModal .export-btn:active {
transform: translateY(0);
}
#settingsModal .export-btn svg {
width: 16px;
height: 16px;
stroke: currentColor;
}
#settingsModal .export-btn.loading {
color: transparent;
pointer-events: none;
}
#settingsModal .export-btn .spinner {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
#settingsModal .export-btn.loading .spinner {
display: block;
}
.toast-container {
@ -1890,7 +1962,7 @@ a:hover {
/* Dashboard section shared styles */
.dashboard-section {
width: 100%;
margin-bottom: 1.5rem;
margin-bottom: 0.5rem;
}
.dashboard-section:not(:first-of-type) {
@ -1932,8 +2004,8 @@ a:hover {
justify-content: space-between;
min-width: calc(100% - 1rem);
flex-wrap: wrap;
gap: 1.5rem;
margin-top: 1.5rem;
gap: 0.5rem;
margin-top: 0.5rem;
width: 100%;
max-width: 100%;
}
@ -1953,7 +2025,7 @@ a:hover {
.chart-container h3 {
font-size: 1rem;
color: var(--secondary-color);
margin-bottom: 1rem;
margin-bottom: 0.5rem;
text-align: center;
}
@ -2373,7 +2445,7 @@ a:hover {
.dashboard-legend {
border: var(--app-border);
border-radius: var(--app-border-radius);
padding: 1.5rem 1.5rem 1.2rem 1.5rem;
padding: 0.85rem 0.5rem 0.5rem 0.5rem;
margin: 1.5rem 0 0 0;
background: var(--container);
box-shadow: 0 2px 8px rgba(37, 100, 235, 0.04);
@ -2976,14 +3048,23 @@ a:hover {
/* Mobile layout adjustments */
.app-container {
grid-template-columns: 1fr; /* Single column layout on mobile */
position: relative;
/* position: relative; */
padding: 0; /* Remove horizontal padding on mobile */
}
.app-container.sidebar-active .sidebar-overlay {
display: block;
opacity: 1;
pointer-events: auto;
}
.main-content {
padding: 0; /* Remove padding from main content */
}
.header-actions {
margin-right: 0.5rem;
}
.dashboard-legend {
margin: 0.5rem 0 0 0; /* Remove horizontal margins */
padding: 1rem 0.5rem 1rem 0.5rem; /* Reduce horizontal padding */
@ -3065,12 +3146,6 @@ a:hover {
pointer-events: none;
}
.app-container.sidebar-active .sidebar-overlay {
display: block;
opacity: 1;
pointer-events: auto;
}
.sidebar-toggle {
display: block !important;
position: static !important;
@ -3084,9 +3159,6 @@ a:hover {
font-size: 1.8rem;
margin-top: 0.5rem;
}
.header-actions {
margin-right: 1rem;
}
.events-table-container {
margin: 0; /* Remove margins */
@ -3149,12 +3221,29 @@ a:hover {
width: 100%;
justify-content: center;
}
#settingsModal .export-section {
flex-direction: column;
}
}
@media (max-width: 600px) {
.container, .app-container, .main-content {
max-height: calc(100vh - 9rem);
padding: 0;
margin: 0;
}
.container {
overflow-y: hidden;
}
#siteTitle {
font-size: 1.5rem;
}
.header-row {
flex-direction: column;
}
form {
padding: 1rem 0.5rem 0.5rem;
}
@ -3166,10 +3255,6 @@ a:hover {
.dashboard-charts-section {
flex-direction: column;
}
.chart-container {
flex: 1 1 100%;
margin-bottom: 1.5rem;
}
.event-row {
grid-template-columns: 1fr;
@ -3238,6 +3323,10 @@ a:hover {
.files-grid {
grid-template-columns: 1fr;
}
#settingsModal .tab-nav {
flex-direction: column;
align-items: center;
}
}
@media (max-width: 380px) {
@ -3246,3 +3335,15 @@ a:hover {
height: 32px;
}
}
@media (max-width: 500px) {
#settingsModal .settings-grid {
grid-template-columns: 1fr;
}
#settingsModal .modal-content {
padding: 1rem;
margin: 1rem;
}
}

View File

@ -619,12 +619,41 @@ app.put('/api/asset', (req, res) => {
return res.status(404).json({ error: 'Asset not found' });
}
const oldAsset = assets[index];
// Preserve creation date
updatedAsset.createdAt = assets[index].createdAt;
// Update the modification date
updatedAsset.updatedAt = new Date().toISOString();
assets[index] = updatedAsset;
// Delete old files if new paths are provided
if (oldAsset.photoPath && updatedAsset.photoPath !== oldAsset.photoPath) {
deleteAssetFileAsync(oldAsset.photoPath);
} else if (updatedAsset.photoPath === oldAsset.photoPath) {
// If no new photoPath is provided, keep the old one
updatedAsset.photoPath = oldAsset.photoPath;
updatedAsset.photoInfo = oldAsset.photoInfo;
updatedAsset.photoPaths = oldAsset.photoPaths || [];
}
if (oldAsset.receiptPath && updatedAsset.receiptPath !== oldAsset.receiptPath) {
deleteAssetFileAsync(oldAsset.receiptPath);
} else if (updatedAsset.receiptPath === oldAsset.receiptPath) {
// If no new receiptPath is provided, keep the old one
updatedAsset.receiptPath = oldAsset.receiptPath;
updatedAsset.receiptInfo = oldAsset.receiptInfo;
updatedAsset.receiptPaths = oldAsset.receiptPaths || [];
}
if (oldAsset.manualPath && updatedAsset.manualPath !== oldAsset.manualPath) {
deleteAssetFileAsync(oldAsset.manualPath);
} else if (updatedAsset.manualPath === oldAsset.manualPath) {
// If no new manualPath is provided, keep the old one
updatedAsset.manualPath = oldAsset.manualPath;
updatedAsset.manualInfo = oldAsset.manualInfo;
updatedAsset.manualPaths = oldAsset.manualPaths || [];
}
const assetToSave = {...oldAsset, ...updatedAsset};
assets[index] = assetToSave;
if (writeJsonFile(assetsFilePath, assets)) {
if (DEBUG) {
@ -822,12 +851,41 @@ app.put('/api/subasset', async (req, res) => {
return res.status(404).json({ error: 'Sub-asset not found' });
}
const oldSubAsset = subAssets[index];
// Preserve creation date
updatedSubAsset.createdAt = subAssets[index].createdAt;
// Update the modification date
updatedSubAsset.updatedAt = new Date().toISOString();
subAssets[index] = updatedSubAsset;
// Delete old files if new paths are provided
if (oldSubAsset.photoPath && updatedSubAsset.photoPath !== oldSubAsset.photoPath) {
deleteAssetFileAsync(oldSubAsset.photoPath);
} else if (updatedSubAsset.photoPath === oldSubAsset.photoPath){
// If no new photoPath is provided, keep the old one
updatedSubAsset.photoPath = oldSubAsset.photoPath;
updatedSubAsset.photoInfo = oldSubAsset.photoInfo;
updatedSubAsset.photoPaths = oldSubAsset.photoPaths || [];
}
if (oldSubAsset.receiptPath && updatedSubAsset.receiptPath !== oldSubAsset.receiptPath) {
deleteAssetFileAsync(oldSubAsset.receiptPath);
} else if (updatedSubAsset.receiptPath === oldSubAsset.receiptPath){
// If no new receiptPath is provided, keep the old one
updatedSubAsset.receiptPath = oldSubAsset.receiptPath;
updatedSubAsset.receiptInfo = oldSubAsset.receiptInfo;
updatedSubAsset.receiptPaths = oldSubAsset.receiptPaths || [];
}
if (oldSubAsset.manualPath && updatedSubAsset.manualPath !== oldSubAsset.manualPath) {
deleteAssetFileAsync(oldSubAsset.manualPath);
} else if (updatedSubAsset.manualPath === oldSubAsset.manualPath){
// If no new manualPath is provided, keep the old one
updatedSubAsset.manualPath = oldSubAsset.manualPath;
updatedSubAsset.manualInfo = oldSubAsset.manualInfo;
updatedSubAsset.manualPaths = oldSubAsset.manualPaths || [];
}
const subAssetToSave = {...oldSubAsset, ...updatedSubAsset};
subAssets[index] = subAssetToSave;
if (writeJsonFile(subAssetsFilePath, subAssets)) {
if (DEBUG) {

View File

@ -53,6 +53,12 @@ function formatNotification(eventType, assetData, baseUrl = '') {
}
} else if (eventType === 'warranty_expiring') {
lines.push(`⏰ Warranty Expiring in ${assetData.days ? assetData.days + ' days' : assetData.time || ''}`);
if (assetData.assetType === 'Component') {
lines.push(`Component: ${assetData.name}`);
} else {
lines.push(`Asset: ${assetData.name}`);
}
if (assetData.modelNumber) lines.push(`Model #: ${assetData.modelNumber}`);
if (assetData.warrantyType) lines.push(assetData.warrantyType);
if (assetData.expirationDate) lines.push(`Expires: ${assetData.expirationDate}`);
} else if (eventType === 'maintenance_schedule') {

View File

@ -244,17 +244,7 @@ function renderAssetList(searchQuery = '') {
})
);
// Also include assets with sub-assets that have any warranty (since they're all active)
const assetsWithWarrantyComponents = assets.filter(a =>
!filteredAssets.includes(a) && // Don't duplicate
!assetsWithActiveComponents.includes(a) && // Don't duplicate
subAssets.some(sa => {
if (sa.parentId !== a.id) return false;
return (sa.warranty && sa.warranty.expirationDate) || sa.warranty?.isLifetime;
})
);
filteredAssets = [...filteredAssets, ...assetsWithActiveComponents, ...assetsWithWarrantyComponents];
filteredAssets = [...filteredAssets, ...assetsWithActiveComponents];
}
}