Updated Asset Entry and Views
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
src/App.vue
132
src/App.vue
@@ -130,6 +130,10 @@
|
||||
<AssetDrawer
|
||||
v-model="assetDrawer"
|
||||
:asset-form="assetForm"
|
||||
:asset-files="activeFiles"
|
||||
:asset-notes="activeNotes"
|
||||
:asset-tasks="activeTasks"
|
||||
:asset-warranties="activeWarranties"
|
||||
:category-items="categoryItems"
|
||||
:drawer-error="drawerError"
|
||||
:entity-options="entityOptions"
|
||||
@@ -139,8 +143,18 @@
|
||||
:selected-template-id="selectedTemplateId"
|
||||
:status-items="statusItems"
|
||||
:template-options="templateOptions"
|
||||
@add-note="addNote"
|
||||
@add-task="addTask"
|
||||
@add-warranty="addWarranty"
|
||||
@delete-file="deleteAssetFile"
|
||||
@delete-note="deleteNote"
|
||||
@delete-task="deleteTask"
|
||||
@delete-warranty="deleteWarranty"
|
||||
@download-file="downloadAssetFile"
|
||||
@save-asset="saveAsset"
|
||||
@select-template="applyTemplate"
|
||||
@toggle-task="toggleTask"
|
||||
@upload-file="uploadAssetFile"
|
||||
/>
|
||||
|
||||
<MassEditDialog
|
||||
@@ -166,7 +180,7 @@ import AssetDrawer from './components/AssetDrawer.vue';
|
||||
import MassEditDialog from './components/MassEditDialog.vue';
|
||||
import TopNav from './components/TopNav.vue';
|
||||
import { apiRequest, loginRequest, saveBlob } from './services/api';
|
||||
import { blankAsset, defaultValueForField, normalizeCustomFields } from './utils/assets';
|
||||
import { blankAsset, defaultValueForField, makeDefaultBooks, normalizeCustomFields } from './utils/assets';
|
||||
import { currency, shortDate } from './utils/format';
|
||||
import { bookItems, customFieldTypes, navItems, statusItems, themeItems } from './constants';
|
||||
|
||||
@@ -193,6 +207,10 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
activeView: 'dashboard',
|
||||
activeFiles: [],
|
||||
activeNotes: [],
|
||||
activeTasks: [],
|
||||
activeWarranties: [],
|
||||
assets: [],
|
||||
assetDrawer: false,
|
||||
assetFilters: { search: '', status: null },
|
||||
@@ -373,7 +391,10 @@ export default {
|
||||
for (const field of normalizeCustomFields(template.custom_fields)) {
|
||||
if (customFields[field.key] === undefined) customFields[field.key] = defaultValueForField(field);
|
||||
}
|
||||
this.assetForm = { ...this.assetForm, ...template.defaults, custom_fields: customFields, template_id: template.id };
|
||||
const merged = { ...this.assetForm, ...template.defaults, custom_fields: customFields, template_id: template.id };
|
||||
// For a brand-new asset, realign the book defaults to the template's method/life.
|
||||
if (!merged.id) merged.books = makeDefaultBooks(merged);
|
||||
this.assetForm = merged;
|
||||
},
|
||||
async assignAsset(form) {
|
||||
this.assignmentSaving = true;
|
||||
@@ -406,11 +427,79 @@ export default {
|
||||
const blob = await (await this.api(`/api/reports/depreciation.pdf?year=${this.reportFilters.year}&book=${this.reportFilters.book}`)).blob();
|
||||
saveBlob(blob, `mixedassets-${this.reportFilters.book}-${this.reportFilters.year}.pdf`);
|
||||
},
|
||||
editAsset(asset) {
|
||||
this.assetForm = { ...blankAsset(), ...asset, custom_fields: asset.custom_fields || {} };
|
||||
this.selectedTemplateId = asset.template_id || null;
|
||||
async editAsset(asset) {
|
||||
this.drawerError = '';
|
||||
const full = await this.refreshActiveAsset(asset.id);
|
||||
this.assetForm = {
|
||||
...blankAsset(),
|
||||
...full,
|
||||
custom_fields: full.custom_fields || {},
|
||||
books: full.books && full.books.length ? full.books : makeDefaultBooks(full)
|
||||
};
|
||||
this.selectedTemplateId = full.template_id || null;
|
||||
this.assetDrawer = true;
|
||||
},
|
||||
async refreshActiveAsset(id) {
|
||||
const data = await (await this.api(`/api/assets/${id}`)).json();
|
||||
const asset = data.asset;
|
||||
this.activeFiles = asset.files || [];
|
||||
this.activeWarranties = asset.warranties || [];
|
||||
this.activeTasks = asset.tasks || [];
|
||||
this.activeNotes = asset.notes_log || [];
|
||||
return asset;
|
||||
},
|
||||
clearActiveCollections() {
|
||||
this.activeFiles = [];
|
||||
this.activeWarranties = [];
|
||||
this.activeTasks = [];
|
||||
this.activeNotes = [];
|
||||
},
|
||||
async uploadAssetFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await this.api(`/api/assets/${this.assetForm.id}/files`, { method: 'POST', body: formData });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
},
|
||||
async downloadAssetFile(file) {
|
||||
const blob = await (await this.api(`/api/assets/${this.assetForm.id}/files/${file.id}`)).blob();
|
||||
saveBlob(blob, file.file_name);
|
||||
},
|
||||
async deleteAssetFile(fileId) {
|
||||
await this.api(`/api/assets/${this.assetForm.id}/files/${fileId}`, { method: 'DELETE' });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
},
|
||||
async addWarranty(warranty) {
|
||||
await this.api(`/api/assets/${this.assetForm.id}/warranties`, { method: 'POST', body: JSON.stringify(warranty) });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
},
|
||||
async deleteWarranty(id) {
|
||||
await this.api(`/api/assets/${this.assetForm.id}/warranties/${id}`, { method: 'DELETE' });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
},
|
||||
async addTask(task) {
|
||||
await this.api(`/api/assets/${this.assetForm.id}/tasks`, { method: 'POST', body: JSON.stringify(task) });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
await this.loadDashboard();
|
||||
},
|
||||
async toggleTask(task) {
|
||||
const status = task.status === 'complete' ? 'open' : 'complete';
|
||||
await this.api(`/api/assets/${this.assetForm.id}/tasks/${task.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
await this.loadDashboard();
|
||||
},
|
||||
async deleteTask(id) {
|
||||
await this.api(`/api/assets/${this.assetForm.id}/tasks/${id}`, { method: 'DELETE' });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
await this.loadDashboard();
|
||||
},
|
||||
async addNote(body) {
|
||||
await this.api(`/api/assets/${this.assetForm.id}/notes`, { method: 'POST', body: JSON.stringify({ body }) });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
},
|
||||
async deleteNote(id) {
|
||||
await this.api(`/api/assets/${this.assetForm.id}/notes/${id}`, { method: 'DELETE' });
|
||||
await this.refreshActiveAsset(this.assetForm.id);
|
||||
},
|
||||
async exportAssets(format) {
|
||||
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
|
||||
saveBlob(blob, `mixedassets-assets.${format}`);
|
||||
@@ -541,27 +630,38 @@ export default {
|
||||
this.assetForm.entity_id = this.entities[0]?.id || null;
|
||||
this.selectedTemplateId = null;
|
||||
this.drawerError = '';
|
||||
this.clearActiveCollections();
|
||||
this.assetDrawer = true;
|
||||
},
|
||||
async saveAsset(assetInput) {
|
||||
this.saving = true;
|
||||
this.drawerError = '';
|
||||
try {
|
||||
const payload = {
|
||||
...assetInput,
|
||||
books: this.bookItems.map((book) => ({
|
||||
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 {
|
||||
|
||||
@@ -1,56 +1,217 @@
|
||||
<template>
|
||||
<v-navigation-drawer :model-value="modelValue" location="right" temporary width="520" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<div class="page-band">
|
||||
<h2 class="text-h6 font-weight-bold">{{ assetForm.id ? 'Edit asset' : 'New asset' }}</h2>
|
||||
<v-navigation-drawer :model-value="modelValue" location="right" temporary width="640" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<div class="page-band d-flex align-center">
|
||||
<h2 class="text-h6 font-weight-bold">{{ localAsset.id ? 'Edit asset' : 'New asset' }}</h2>
|
||||
<v-spacer />
|
||||
<span v-if="localAsset.asset_id" class="text-caption quiet">{{ localAsset.asset_id }}</span>
|
||||
</div>
|
||||
<div class="pa-4">
|
||||
<v-select :model-value="selectedTemplateId" :items="templateOptions" label="Template" clearable @update:model-value="$emit('select-template', $event)" />
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="localAsset.asset_id" label="Asset ID" />
|
||||
<v-select v-model="localAsset.entity_id" :items="entityOptions" label="Entity" />
|
||||
<v-text-field v-model="localAsset.description" class="full" label="Description" />
|
||||
<v-select v-model="localAsset.category" :items="categoryItems" label="Category" />
|
||||
<v-select v-model="localAsset.status" :items="statusItems" label="Status" />
|
||||
<v-text-field v-model.number="localAsset.acquisition_cost" type="number" label="Acquisition cost" />
|
||||
<v-text-field v-model.number="localAsset.salvage_value" type="number" label="Salvage value" />
|
||||
<v-text-field v-model="localAsset.acquired_date" type="date" label="Acquired date" />
|
||||
<v-text-field v-model="localAsset.in_service_date" type="date" label="In-service date" />
|
||||
<v-text-field v-model.number="localAsset.useful_life_months" type="number" label="Useful life months" />
|
||||
<v-select v-model="localAsset.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Method" />
|
||||
<v-text-field v-model="localAsset.location" label="Location" />
|
||||
<v-text-field v-model="localAsset.department" label="Department" />
|
||||
<v-text-field v-model="localAsset.custodian" label="Custodian" />
|
||||
<v-text-field v-model="localAsset.vendor" label="Vendor" />
|
||||
<v-text-field v-model="localAsset.serial_number" label="Serial number" />
|
||||
<v-text-field v-model="localAsset.invoice_number" label="Invoice number" />
|
||||
<v-text-field v-model="localAsset.gl_asset_account" label="Asset GL" />
|
||||
<v-text-field v-model="localAsset.gl_expense_account" label="Expense GL" />
|
||||
<v-textarea v-model="localAsset.notes" class="full" label="Notes" rows="2" />
|
||||
</div>
|
||||
|
||||
<template v-if="selectedTemplateFields.length">
|
||||
<v-divider class="my-4" />
|
||||
<h3 class="section-title mb-3">Template fields</h3>
|
||||
<div class="form-grid">
|
||||
<template v-for="field in selectedTemplateFields" :key="field.key">
|
||||
<v-switch
|
||||
v-if="field.type === 'bool'"
|
||||
v-model="localAsset.custom_fields[field.key]"
|
||||
:label="field.label"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="localAsset.custom_fields[field.key]"
|
||||
:label="field.label"
|
||||
:type="inputTypeForCustomField(field)"
|
||||
:rules="field.required ? [requiredRule] : []"
|
||||
/>
|
||||
<v-tabs v-model="tab" density="compact" color="primary" class="px-2">
|
||||
<v-tab value="details">Details</v-tab>
|
||||
<v-tab value="books">Books</v-tab>
|
||||
<v-tab value="files">Files <v-badge v-if="assetFiles.length" :content="assetFiles.length" inline /></v-tab>
|
||||
<v-tab value="warranties">Warranties <v-badge v-if="assetWarranties.length" :content="assetWarranties.length" inline /></v-tab>
|
||||
<v-tab value="tasks">Tasks <v-badge v-if="assetTasks.length" :content="assetTasks.length" inline /></v-tab>
|
||||
<v-tab value="notes">Notes <v-badge v-if="assetNotes.length" :content="assetNotes.length" inline /></v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="pa-4">
|
||||
<v-window v-model="tab">
|
||||
<!-- DETAILS -->
|
||||
<v-window-item value="details">
|
||||
<v-select :model-value="selectedTemplateId" :items="templateOptions" label="Template" clearable @update:model-value="$emit('select-template', $event)" />
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="localAsset.asset_id" label="Asset ID" />
|
||||
<v-select v-model="localAsset.entity_id" :items="entityOptions" label="Entity" />
|
||||
<v-text-field v-model="localAsset.description" class="full" label="Description" />
|
||||
<v-select v-model="localAsset.category" :items="categoryItems" label="Category" />
|
||||
<v-select v-model="localAsset.status" :items="statusItems" label="Status" />
|
||||
<v-text-field v-model.number="localAsset.acquisition_cost" type="number" label="Acquisition cost" />
|
||||
<v-text-field v-model.number="localAsset.salvage_value" type="number" label="Salvage value" />
|
||||
<v-text-field v-model.number="localAsset.land_value" type="number" label="Land value (non-depreciable)" />
|
||||
<v-text-field v-model.number="localAsset.prior_depreciation" type="number" label="Prior depreciation" />
|
||||
<v-text-field v-model="localAsset.acquired_date" type="date" label="Acquired date" />
|
||||
<v-text-field v-model="localAsset.in_service_date" type="date" label="In-service date" />
|
||||
<v-text-field v-model.number="localAsset.useful_life_months" type="number" label="Useful life months" />
|
||||
<v-select v-model="localAsset.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Default method" />
|
||||
<v-select v-model="localAsset.new_or_used" :items="newUsedItems" item-title="title" item-value="value" label="New / used" />
|
||||
<v-text-field v-model.number="localAsset.business_use_percent" type="number" label="Business use %" />
|
||||
<v-text-field v-model.number="localAsset.investment_use_percent" type="number" label="Investment use %" />
|
||||
<v-switch v-model="localAsset.listed_property" label="Listed property (luxury auto limits)" color="primary" density="compact" hide-details class="full" />
|
||||
|
||||
<v-text-field v-model="localAsset.location" label="Location" />
|
||||
<v-text-field v-model="localAsset.department" label="Department" />
|
||||
<v-text-field v-model="localAsset.group_name" label="Group" />
|
||||
<v-text-field v-model="localAsset.custodian" label="Custodian" />
|
||||
<v-text-field v-model="localAsset.barcode_value" label="Barcode / tag value" />
|
||||
<v-select v-model="localAsset.condition" :items="conditionItems" clearable label="Condition" />
|
||||
<v-text-field v-model="localAsset.vendor" label="Vendor" />
|
||||
<v-text-field v-model="localAsset.serial_number" label="Serial number" />
|
||||
<v-text-field v-model="localAsset.invoice_number" label="Invoice number" />
|
||||
<v-text-field v-model="localAsset.gl_asset_account" label="Asset GL" />
|
||||
<v-text-field v-model="localAsset.gl_expense_account" label="Expense GL" />
|
||||
<v-text-field v-model="localAsset.gl_accumulated_account" label="Accumulated GL" />
|
||||
<v-textarea v-model="localAsset.notes" class="full" label="Summary notes" rows="2" />
|
||||
</div>
|
||||
|
||||
<template v-if="selectedTemplateFields.length">
|
||||
<v-divider class="my-4" />
|
||||
<h3 class="section-title mb-3">Template fields</h3>
|
||||
<div class="form-grid">
|
||||
<template v-for="field in selectedTemplateFields" :key="field.key">
|
||||
<v-switch
|
||||
v-if="field.type === 'bool'"
|
||||
v-model="localAsset.custom_fields[field.key]"
|
||||
:label="field.label"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="localAsset.custom_fields[field.key]"
|
||||
:label="field.label"
|
||||
:type="inputTypeForCustomField(field)"
|
||||
:rules="field.required ? [requiredRule] : []"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</v-window-item>
|
||||
|
||||
<!-- BOOKS -->
|
||||
<v-window-item value="books">
|
||||
<p class="quiet text-body-2 mb-3">
|
||||
Each book can carry its own cost, method, life, §179, bonus and convention. Leave cost blank to inherit the acquisition cost.
|
||||
</p>
|
||||
<v-expansion-panels variant="accordion" multiple>
|
||||
<v-expansion-panel v-for="book in localAsset.books" :key="book.book_type">
|
||||
<v-expansion-panel-title>
|
||||
<div class="d-flex align-center" style="gap:10px;width:100%">
|
||||
<v-switch v-model="book.active" color="primary" density="compact" hide-details @click.stop />
|
||||
<span class="font-weight-bold">{{ book.book_type }}</span>
|
||||
<v-spacer />
|
||||
<span class="text-caption quiet">{{ methodLabel(book.depreciation_method) }}</span>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model.number="book.cost" type="number" label="Cost (blank = inherit)" />
|
||||
<v-text-field v-model.number="book.useful_life_months" type="number" label="Useful life months" />
|
||||
<v-select v-model="book.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Method" class="full" />
|
||||
<v-select v-model="book.convention" :items="conventionItems" item-title="title" item-value="value" label="Convention" />
|
||||
<v-text-field v-model.number="book.section_179_amount" type="number" label="§179 amount" />
|
||||
<v-text-field v-model.number="book.bonus_percent" type="number" label="Bonus %" />
|
||||
<v-text-field v-model.number="book.business_use_percent" type="number" label="Business use %" />
|
||||
<v-text-field v-model.number="book.investment_use_percent" type="number" label="Investment use %" />
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-window-item>
|
||||
|
||||
<!-- FILES -->
|
||||
<v-window-item value="files">
|
||||
<template v-if="localAsset.id">
|
||||
<v-btn variant="tonal" prepend-icon="mdi-paperclip" class="mb-3" @click="$refs.fileInput.click()">Attach file</v-btn>
|
||||
<input ref="fileInput" hidden type="file" @change="onFilePick" />
|
||||
<p v-if="!assetFiles.length" class="quiet text-body-2">Store invoices, photos, manuals, and insurance records here.</p>
|
||||
<v-list v-else lines="two" density="compact">
|
||||
<v-list-item v-for="file in assetFiles" :key="file.id" prepend-icon="mdi-file-outline">
|
||||
<v-list-item-title>{{ file.file_name }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatBytes(file.size) }} · {{ file.mime_type }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-download" size="small" variant="text" @click="$emit('download-file', file)" />
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('delete-file', file.id)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then attach files.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- WARRANTIES -->
|
||||
<v-window-item value="warranties">
|
||||
<template v-if="localAsset.id">
|
||||
<v-list v-if="assetWarranties.length" lines="two" density="compact" class="mb-3">
|
||||
<v-list-item v-for="w in assetWarranties" :key="w.id" prepend-icon="mdi-shield-check-outline">
|
||||
<v-list-item-title>{{ w.provider || 'Warranty' }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ w.start_date || '—' }} → {{ w.expiration_date || '—' }}
|
||||
<template v-if="w.coverage_details"> · {{ w.coverage_details }}</template>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('delete-warranty', w.id)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider class="mb-3" />
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="warrantyForm.provider" label="Provider" />
|
||||
<v-text-field v-model="warrantyForm.phone" label="Phone" />
|
||||
<v-text-field v-model="warrantyForm.start_date" type="date" label="Start date" />
|
||||
<v-text-field v-model="warrantyForm.expiration_date" type="date" label="Expiration date" />
|
||||
<v-text-field v-model="warrantyForm.warranty_limit" label="Limit (e.g. miles)" />
|
||||
<v-text-field v-model="warrantyForm.actual_usage" label="Actual usage" />
|
||||
<v-textarea v-model="warrantyForm.coverage_details" class="full" label="Coverage details" rows="2" />
|
||||
</div>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" @click="addWarranty">Add warranty</v-btn>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then add warranties.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- TASKS -->
|
||||
<v-window-item value="tasks">
|
||||
<template v-if="localAsset.id">
|
||||
<v-list v-if="assetTasks.length" lines="two" density="compact" class="mb-3">
|
||||
<v-list-item v-for="task in assetTasks" :key="task.id">
|
||||
<template #prepend>
|
||||
<v-checkbox-btn
|
||||
:model-value="task.status === 'complete'"
|
||||
@update:model-value="$emit('toggle-task', task)"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item-title :class="{ 'text-decoration-line-through quiet': task.status === 'complete' }">{{ task.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ task.type }} · due {{ task.due_date || '—' }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('delete-task', task.id)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider class="mb-3" />
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="taskForm.title" class="full" label="Task title" />
|
||||
<v-select v-model="taskForm.type" :items="taskTypeItems" label="Type" />
|
||||
<v-text-field v-model="taskForm.due_date" type="date" label="Due date" />
|
||||
<v-textarea v-model="taskForm.notes" class="full" label="Notes" rows="2" />
|
||||
</div>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" :disabled="!taskForm.title" @click="addTask">Add task</v-btn>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then add tasks.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- NOTES -->
|
||||
<v-window-item value="notes">
|
||||
<template v-if="localAsset.id">
|
||||
<v-textarea v-model="noteDraft" label="Add a note" rows="3" auto-grow />
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mb-3" :disabled="!noteDraft.trim()" @click="addNote">Add note</v-btn>
|
||||
<v-timeline v-if="assetNotes.length" side="end" density="compact" truncate-line="both">
|
||||
<v-timeline-item v-for="note in assetNotes" :key="note.id" dot-color="primary" size="x-small">
|
||||
<div class="d-flex">
|
||||
<div>
|
||||
<div class="text-body-2" style="white-space:pre-wrap">{{ note.body }}</div>
|
||||
<div class="text-caption quiet">{{ note.author || 'Unknown' }} · {{ note.created_at }}</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="$emit('delete-note', note.id)" />
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
<p v-else class="quiet text-body-2">No notes yet.</p>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then keep running notes.</p>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
|
||||
<v-alert v-if="drawerError" class="mt-4" density="compact" type="error">{{ drawerError }}</v-alert>
|
||||
<div class="toolbar-row mt-4">
|
||||
@@ -63,12 +224,25 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputTypeForCustomField } from '../utils/assets';
|
||||
import { inputTypeForCustomField, makeDefaultBooks } from '../utils/assets';
|
||||
import { clonePlain } from '../utils/clone';
|
||||
import { conditionItems, conventionItems, newUsedItems, taskTypeItems } from '../constants';
|
||||
|
||||
function blankWarranty() {
|
||||
return { provider: '', phone: '', start_date: '', expiration_date: '', warranty_limit: '', actual_usage: '', coverage_details: '' };
|
||||
}
|
||||
|
||||
function blankTask() {
|
||||
return { title: '', type: 'maintenance', due_date: '', notes: '' };
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
assetForm: { type: Object, required: true },
|
||||
assetFiles: { type: Array, default: () => [] },
|
||||
assetNotes: { type: Array, default: () => [] },
|
||||
assetTasks: { type: Array, default: () => [] },
|
||||
assetWarranties: { type: Array, default: () => [] },
|
||||
categoryItems: { type: Array, required: true },
|
||||
drawerError: { type: String, default: '' },
|
||||
entityOptions: { type: Array, required: true },
|
||||
@@ -80,23 +254,79 @@ export default {
|
||||
statusItems: { type: Array, required: true },
|
||||
templateOptions: { type: Array, required: true }
|
||||
},
|
||||
emits: ['save-asset', 'select-template', 'update:modelValue'],
|
||||
emits: [
|
||||
'save-asset', 'select-template', 'update:modelValue',
|
||||
'upload-file', 'download-file', 'delete-file',
|
||||
'add-warranty', 'delete-warranty',
|
||||
'add-task', 'toggle-task', 'delete-task',
|
||||
'add-note', 'delete-note'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
localAsset: clonePlain(this.assetForm),
|
||||
tab: 'details',
|
||||
conditionItems,
|
||||
conventionItems,
|
||||
newUsedItems,
|
||||
taskTypeItems,
|
||||
localAsset: this.normalize(this.assetForm),
|
||||
warrantyForm: blankWarranty(),
|
||||
taskForm: blankTask(),
|
||||
noteDraft: '',
|
||||
requiredRule: (value) => value !== null && value !== undefined && value !== '' || 'Required'
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
assetForm: {
|
||||
handler(value) {
|
||||
this.localAsset = clonePlain(value);
|
||||
this.localAsset = this.normalize(value);
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
modelValue(open) {
|
||||
if (open) {
|
||||
this.tab = 'details';
|
||||
this.warrantyForm = blankWarranty();
|
||||
this.taskForm = blankTask();
|
||||
this.noteDraft = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
inputTypeForCustomField
|
||||
inputTypeForCustomField,
|
||||
normalize(value) {
|
||||
const asset = clonePlain(value);
|
||||
if (!Array.isArray(asset.books) || !asset.books.length) {
|
||||
asset.books = makeDefaultBooks(asset);
|
||||
}
|
||||
if (!asset.custom_fields || typeof asset.custom_fields !== 'object') asset.custom_fields = {};
|
||||
return asset;
|
||||
},
|
||||
methodLabel(code) {
|
||||
return this.methodItems.find((item) => item.value === code)?.title || code;
|
||||
},
|
||||
formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
},
|
||||
onFilePick(event) {
|
||||
const [file] = event.target.files || [];
|
||||
if (file) this.$emit('upload-file', file);
|
||||
event.target.value = '';
|
||||
},
|
||||
addWarranty() {
|
||||
this.$emit('add-warranty', { ...this.warrantyForm });
|
||||
this.warrantyForm = blankWarranty();
|
||||
},
|
||||
addTask() {
|
||||
this.$emit('add-task', { ...this.taskForm });
|
||||
this.taskForm = blankTask();
|
||||
},
|
||||
addNote() {
|
||||
this.$emit('add-note', this.noteDraft.trim());
|
||||
this.noteDraft = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,23 @@ export const bookItems = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||
|
||||
export const statusItems = ['in_service', 'cip', 'reserved', 'checked_out', 'disposed', 'retired'];
|
||||
|
||||
export const conventionItems = [
|
||||
{ title: 'Half-year', value: 'half_year' },
|
||||
{ title: 'Mid-quarter', value: 'mid_quarter' },
|
||||
{ title: 'Mid-month', value: 'mid_month' },
|
||||
{ title: 'Full-month', value: 'full_month' },
|
||||
{ title: 'None', value: 'none' }
|
||||
];
|
||||
|
||||
export const newUsedItems = [
|
||||
{ title: 'New', value: 'new' },
|
||||
{ title: 'Used', value: 'used' }
|
||||
];
|
||||
|
||||
export const taskTypeItems = ['maintenance', 'inspection', 'calibration', 'renewal', 'compliance', 'other'];
|
||||
|
||||
export const conditionItems = ['excellent', 'good', 'fair', 'poor', 'out_of_service'];
|
||||
|
||||
export const customFieldTypes = [
|
||||
{ title: 'Text', value: 'text' },
|
||||
{ title: 'Number', value: 'numeric' },
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
export const BOOK_TYPES = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||
|
||||
export function makeDefaultBooks(asset = {}) {
|
||||
return BOOK_TYPES.map((bookType) => ({
|
||||
book_type: bookType,
|
||||
active: bookType === 'GAAP' || bookType === 'FEDERAL',
|
||||
cost: null,
|
||||
depreciation_method: asset.depreciation_method || 'straight_line',
|
||||
convention: 'half_year',
|
||||
useful_life_months: asset.useful_life_months || 60,
|
||||
section_179_amount: 0,
|
||||
bonus_percent: 0,
|
||||
business_use_percent: 100,
|
||||
investment_use_percent: 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function blankAsset() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return {
|
||||
const base = {
|
||||
asset_id: '',
|
||||
entity_id: null,
|
||||
description: '',
|
||||
@@ -11,18 +28,30 @@ export function blankAsset() {
|
||||
in_service_date: today,
|
||||
useful_life_months: 60,
|
||||
salvage_value: 0,
|
||||
land_value: 0,
|
||||
prior_depreciation: 0,
|
||||
depreciation_method: 'straight_line',
|
||||
location: '',
|
||||
department: '',
|
||||
group_name: '',
|
||||
custodian: '',
|
||||
vendor: '',
|
||||
serial_number: '',
|
||||
invoice_number: '',
|
||||
gl_asset_account: '',
|
||||
gl_expense_account: '',
|
||||
gl_accumulated_account: '',
|
||||
barcode_value: '',
|
||||
condition: '',
|
||||
new_or_used: 'new',
|
||||
listed_property: false,
|
||||
business_use_percent: 100,
|
||||
investment_use_percent: 0,
|
||||
notes: '',
|
||||
custom_fields: {}
|
||||
};
|
||||
base.books = makeDefaultBooks(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
export function slugFieldKey(value) {
|
||||
|
||||
Reference in New Issue
Block a user