mirror of
https://github.com/DumbWareio/DumbAssets.git
synced 2026-01-09 06:10:52 +08:00
commit
dcff8c525d
50
README.md
50
README.md
@ -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)
|
||||
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@ -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 |
@ -12,6 +12,6 @@ function demoModeMiddleware(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
export {
|
||||
module.exports = {
|
||||
demoModeMiddleware,
|
||||
}
|
||||
197
package-lock.json
generated
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 |
@ -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 |
@ -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>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings modal actions - now outside of individual forms -->
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,13 +3048,22 @@ 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 */
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
66
server.js
66
server.js
@ -618,14 +618,43 @@ app.put('/api/asset', (req, res) => {
|
||||
if (index === -1) {
|
||||
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) {
|
||||
console.log('[DEBUG] Asset edited:', { id: updatedAsset.id, name: updatedAsset.name, modelNumber: updatedAsset.modelNumber });
|
||||
@ -821,13 +850,42 @@ app.put('/api/subasset', async (req, res) => {
|
||||
if (index === -1) {
|
||||
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) {
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user