From 8335f1af1b47b00d601bd56af60a2a1f4fefd39b Mon Sep 17 00:00:00 2001 From: mpuckett Date: Wed, 3 Jun 2026 14:12:32 -0500 Subject: [PATCH] Updated Asset Entry and Views --- README.md | 4 +- server/db.js | 8 + server/routes/assets.js | 92 +++++++++ server/services/assets.js | 8 + server/smoke-test.js | 63 +++++- src/App.vue | 132 +++++++++++-- src/components/AssetDrawer.vue | 338 +++++++++++++++++++++++++++------ src/constants.js | 17 ++ src/utils/assets.js | 31 ++- 9 files changed, 620 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 5f5d562..9e94b46 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # MixedAssets -MixedAssets is a fixed asset management and depreciation SPA for small and mid-sized organizations. This first build includes an Express API, Vue/Vuetify frontend, SQLite persistence, role-aware login, asset register, templates, JSON tax-rule sets, depreciation reports, import/export, file storage scaffolding, and admin configuration. +MixedAssets is a fixed asset management and depreciation SPA for small and mid-sized organizations. This build includes an Express API, Vue/Vuetify frontend, SQLite persistence, role-aware login, asset register, templates, JSON tax-rule sets, depreciation reports, import/export, and admin configuration. + +Each asset carries six independent books (GAAP, Federal, State, AMT, ACE, User-defined), each with its own cost, depreciation method, useful life, §179 amount, bonus percentage, and convention. The asset workbench also includes a File Cabinet (invoices, photos, manuals), warranty/service-agreement tracking, a per-asset task list, and a running notes timeline. ## Run diff --git a/server/db.js b/server/db.js index 281ae3d..184d1f6 100644 --- a/server/db.js +++ b/server/db.js @@ -327,6 +327,14 @@ function initialize() { updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS asset_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id INTEGER REFERENCES users(id), diff --git a/server/routes/assets.js b/server/routes/assets.js index f6cd114..583adc4 100644 --- a/server/routes/assets.js +++ b/server/routes/assets.js @@ -70,6 +70,50 @@ router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'fi res.status(201).json({ file: one('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE id = ?', [result.lastInsertRowid]) }); }); +router.get('/assets/:id/files/:fileId', (req, res) => { + const file = one('SELECT * FROM asset_files WHERE id = ? AND asset_id = ?', [req.params.fileId, req.params.id]); + if (!file) return res.status(404).json({ error: 'File not found' }); + res.setHeader('Content-Type', file.mime_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${file.file_name}"`); + return res.send(Buffer.from(file.content_base64, 'base64')); +}); + +router.delete('/assets/:id/files/:fileId', requireRole('admin', 'finance', 'operations'), (req, res) => { + const result = run('DELETE FROM asset_files WHERE id = ? AND asset_id = ?', [req.params.fileId, req.params.id]); + if (!result.changes) return res.status(404).json({ error: 'File not found' }); + audit(req.user.id, 'delete', 'asset_file', req.params.fileId, null, { asset_id: req.params.id }); + return res.status(204).end(); +}); + +router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operations'), (req, res) => { + const b = req.body; + const result = run( + `INSERT INTO warranties ( + asset_id, provider, phone, start_date, expiration_date, warranty_limit, + actual_usage, coverage_details, document_name, document_base64, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + req.params.id, b.provider || null, b.phone || null, b.start_date || null, b.expiration_date || null, + b.warranty_limit || null, b.actual_usage || null, b.coverage_details || null, + b.document_name || null, b.document_base64 || null, now() + ] + ); + audit(req.user.id, 'create', 'warranty', result.lastInsertRowid, null, { ...b, document_base64: b.document_base64 ? '[stored]' : null }); + res.status(201).json({ + warranty: one( + 'SELECT id, provider, phone, start_date, expiration_date, warranty_limit, actual_usage, coverage_details, document_name, created_at FROM warranties WHERE id = ?', + [result.lastInsertRowid] + ) + }); +}); + +router.delete('/assets/:id/warranties/:warrantyId', requireRole('admin', 'finance', 'operations'), (req, res) => { + const result = run('DELETE FROM warranties WHERE id = ? AND asset_id = ?', [req.params.warrantyId, req.params.id]); + if (!result.changes) return res.status(404).json({ error: 'Warranty not found' }); + audit(req.user.id, 'delete', 'warranty', req.params.warrantyId, null, { asset_id: req.params.id }); + return res.status(204).end(); +}); + router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (req, res) => { const created = now(); const result = run( @@ -80,4 +124,52 @@ router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) }); }); +router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => { + const before = one('SELECT * FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]); + if (!before) return res.status(404).json({ error: 'Task not found' }); + run( + 'UPDATE tasks SET title = ?, type = ?, status = ?, due_date = ?, notes = ?, updated_at = ? WHERE id = ?', + [ + req.body.title ?? before.title, + req.body.type ?? before.type, + req.body.status ?? before.status, + req.body.due_date ?? before.due_date, + req.body.notes ?? before.notes, + now(), + req.params.taskId + ] + ); + audit(req.user.id, 'update', 'task', req.params.taskId, before, req.body); + return res.json({ task: one('SELECT * FROM tasks WHERE id = ?', [req.params.taskId]) }); +}); + +router.delete('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => { + const result = run('DELETE FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]); + if (!result.changes) return res.status(404).json({ error: 'Task not found' }); + audit(req.user.id, 'delete', 'task', req.params.taskId, null, { asset_id: req.params.id }); + return res.status(204).end(); +}); + +router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'), (req, res) => { + if (!req.body.body) return res.status(400).json({ error: 'Note body is required' }); + const result = run( + 'INSERT INTO asset_notes (asset_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', + [req.params.id, req.body.body, req.user.id, now()] + ); + audit(req.user.id, 'create', 'asset_note', result.lastInsertRowid, null, { asset_id: req.params.id }); + res.status(201).json({ + note: one( + 'SELECT n.id, n.body, n.created_at, u.name AS author FROM asset_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', + [result.lastInsertRowid] + ) + }); +}); + +router.delete('/assets/:id/notes/:noteId', requireRole('admin', 'finance', 'operations'), (req, res) => { + const result = run('DELETE FROM asset_notes WHERE id = ? AND asset_id = ?', [req.params.noteId, req.params.id]); + if (!result.changes) return res.status(404).json({ error: 'Note not found' }); + audit(req.user.id, 'delete', 'asset_note', req.params.noteId, null, { asset_id: req.params.id }); + return res.status(204).end(); +}); + module.exports = router; diff --git a/server/services/assets.js b/server/services/assets.js index 52f1085..37ed21a 100644 --- a/server/services/assets.js +++ b/server/services/assets.js @@ -63,6 +63,14 @@ function hydrateAsset(id) { asset.tasks = all('SELECT * FROM tasks WHERE asset_id = ? ORDER BY due_date IS NULL, due_date', [id]); asset.warranties = all('SELECT id, provider, phone, start_date, expiration_date, warranty_limit, actual_usage, coverage_details, document_name, created_at FROM warranties WHERE asset_id = ?', [id]); asset.files = all('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE asset_id = ?', [id]); + asset.notes_log = all( + `SELECT n.id, n.body, n.created_at, u.name AS author + FROM asset_notes n + LEFT JOIN users u ON u.id = n.created_by + WHERE n.asset_id = ? + ORDER BY n.id DESC`, + [id] + ); return asset; } diff --git a/server/smoke-test.js b/server/smoke-test.js index b109e67..916e969 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -141,10 +141,71 @@ async function main() { acquired_date: '2026-01-05', in_service_date: '2026-01-05', useful_life_months: 60, - depreciation_method: 'macrs_gds_200db_5' + depreciation_method: 'macrs_gds_200db_5', + books: [ + { book_type: 'GAAP', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, cost: 2400 }, + { book_type: 'FEDERAL', active: true, depreciation_method: 'macrs_gds_200db_5', convention: 'half_year', useful_life_months: 60, section_179_amount: 500, bonus_percent: 0, cost: 2400 } + ] }) }); + const assetId = assetResult.asset.id; + + // Per-book persistence: GAAP and FEDERAL must keep distinct methods. + const hydrated = await request(`/api/assets/${assetId}`, { headers: auth }); + const gaapBook = hydrated.asset.books.find((book) => book.book_type === 'GAAP'); + const federalBook = hydrated.asset.books.find((book) => book.book_type === 'FEDERAL'); + if (gaapBook?.depreciation_method !== 'straight_line' || federalBook?.depreciation_method !== 'macrs_gds_200db_5') { + throw new Error('Per-book depreciation methods did not persist independently'); + } + if (Number(federalBook.section_179_amount) !== 500) { + throw new Error('Per-book Section 179 amount did not persist'); + } + + // File cabinet (multipart upload bypasses the JSON request helper) + const uploadForm = new FormData(); + uploadForm.append('file', new Blob(['invoice contents'], { type: 'text/plain' }), 'invoice.txt'); + const uploadResponse = await fetch(`${baseUrl}/api/assets/${assetId}/files`, { method: 'POST', headers: auth, body: uploadForm }); + const fileResult = await uploadResponse.json(); + if (!uploadResponse.ok || !fileResult.file?.id) throw new Error('Asset file upload did not return a file'); + const fileDownload = await fetch(`${baseUrl}/api/assets/${assetId}/files/${fileResult.file.id}`, { headers: auth }); + if (!fileDownload.ok || (await fileDownload.text()) !== 'invoice contents') { + throw new Error('Asset file download did not return stored contents'); + } + + // Warranty + const warrantyResult = await request(`/api/assets/${assetId}/warranties`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ provider: 'Acme Care', start_date: '2026-01-05', expiration_date: '2029-01-05', coverage_details: 'Parts and labor' }) + }); + if (!warrantyResult.warranty?.id) throw new Error('Warranty was not created'); + + // Task lifecycle + const taskResult = await request(`/api/assets/${assetId}/tasks`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ title: 'Calibrate device', type: 'calibration', due_date: '2026-03-01' }) + }); + const completedTask = await request(`/api/assets/${assetId}/tasks/${taskResult.task.id}`, { + method: 'PUT', + headers: auth, + body: JSON.stringify({ status: 'complete' }) + }); + if (completedTask.task.status !== 'complete') throw new Error('Task status update did not persist'); + + // Notes + await request(`/api/assets/${assetId}/notes`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ body: 'Received and tagged in receiving dock.' }) + }); + + const detailed = await request(`/api/assets/${assetId}`, { headers: auth }); + if (!detailed.asset.files.length || !detailed.asset.warranties.length || !detailed.asset.notes_log.length) { + throw new Error('Asset detail collections (files/warranties/notes) were not returned'); + } + const employeeResult = await request('/api/employees', { method: 'POST', headers: auth, diff --git a/src/App.vue b/src/App.vue index 3b48aed..094aba4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -130,6 +130,10 @@ ({ - book_type: book, - depreciation_method: assetInput.depreciation_method, - useful_life_months: assetInput.useful_life_months, - cost: assetInput.acquisition_cost - })) - }; + const books = (assetInput.books && assetInput.books.length ? assetInput.books : makeDefaultBooks(assetInput)) + .map((book) => ({ + ...book, + cost: book.cost === '' || book.cost === null || book.cost === undefined ? null : Number(book.cost) + })); + const payload = { ...assetInput, books }; const method = assetInput.id ? 'PUT' : 'POST'; const url = assetInput.id ? `/api/assets/${assetInput.id}` : '/api/assets'; - await this.api(url, { method, body: JSON.stringify(payload) }); - this.assetDrawer = false; + const saved = await (await this.api(url, { method, body: JSON.stringify(payload) })).json(); await this.loadAssets(); await this.loadDashboard(); + if (!assetInput.id && saved.asset) { + // Keep the drawer open on the freshly created asset so files, warranties, + // tasks, and notes can be attached without reopening. + this.assetForm = { + ...blankAsset(), + ...saved.asset, + custom_fields: saved.asset.custom_fields || {}, + books: saved.asset.books && saved.asset.books.length ? saved.asset.books : makeDefaultBooks(saved.asset) + }; + this.selectedTemplateId = saved.asset.template_id || null; + await this.refreshActiveAsset(saved.asset.id); + } else { + this.assetDrawer = false; + } } catch (error) { this.drawerError = error.message; } finally { diff --git a/src/components/AssetDrawer.vue b/src/components/AssetDrawer.vue index 3355375..1615ae5 100644 --- a/src/components/AssetDrawer.vue +++ b/src/components/AssetDrawer.vue @@ -1,56 +1,217 @@