Updated Asset Entry and Views

This commit is contained in:
2026-06-03 14:12:32 -05:00
parent 2f13b8c590
commit 8335f1af1b
9 changed files with 620 additions and 73 deletions

View File

@@ -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>