1080 lines
56 KiB
Vue
1080 lines
56 KiB
Vue
<template>
|
||
<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>
|
||
|
||
<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="photos">Photos <v-badge v-if="assetPhotos.length" :content="assetPhotos.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-tab value="disposal">Disposal <v-badge v-if="assetDisposals.length" :content="assetDisposals.length" inline /></v-tab>
|
||
<v-tab value="lease">Lease <v-badge v-if="assetLeases.length" :content="assetLeases.length" inline /></v-tab>
|
||
<v-tab value="pm">PM <v-badge v-if="assetPm.length" :content="assetPm.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"
|
||
:hint="localAsset.id ? '' : 'Leave blank to auto-assign from the category’s ID template'"
|
||
:persistent-hint="!localAsset.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-text-field
|
||
:model-value="assetClassNumber"
|
||
label="Asset class number"
|
||
readonly
|
||
:hint="assetClassNumber ? 'Shared by all assets in this category' : 'Set on the category in Admin → Application Configuration'"
|
||
persistent-hint
|
||
/>
|
||
<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 (≤50% business use → ADS straight-line)" color="primary" density="compact" hide-details class="full" />
|
||
<v-switch v-model="localAsset.passenger_auto" label="Passenger automobile (§280F annual depreciation caps)" color="primary" density="compact" hide-details class="full" />
|
||
|
||
<v-select
|
||
v-model="localAsset.special_zone"
|
||
:items="zoneItems"
|
||
item-title="title"
|
||
item-value="value"
|
||
label="Special depreciation zone"
|
||
class="full"
|
||
/>
|
||
<v-alert v-if="zoneGuidance" type="info" variant="tonal" density="compact" class="full">{{ zoneGuidance }}</v-alert>
|
||
|
||
<v-combobox v-model="localAsset.location" :items="locationItems" label="Location" clearable />
|
||
<v-combobox v-model="localAsset.department" :items="departmentItems" label="Department" clearable />
|
||
<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-combobox v-model="localAsset.vendor" :items="vendorItems" label="Vendor" clearable />
|
||
<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>
|
||
|
||
<v-divider class="my-4" />
|
||
<div class="toolbar-row align-center">
|
||
<div>
|
||
<h3 class="section-title">Barcode label</h3>
|
||
<div class="quiet text-body-2">Uses the barcode value, or the Asset ID when blank.</div>
|
||
</div>
|
||
<v-spacer />
|
||
<v-btn size="small" variant="text" @click="useAssetIdAsBarcode">Use Asset ID</v-btn>
|
||
<v-btn size="small" variant="tonal" prepend-icon="mdi-printer" :disabled="!barcodeValue" @click="$emit('print-label', localAsset)">Print</v-btn>
|
||
</div>
|
||
<div v-if="barcodeValue" class="label-cell mt-2" style="max-width:240px">
|
||
<BarcodeLabel :value="barcodeValue" :caption="localAsset.description" />
|
||
</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>
|
||
</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>
|
||
|
||
<!-- PHOTOS (identification) -->
|
||
<v-window-item value="photos">
|
||
<template v-if="localAsset.id">
|
||
<div class="toolbar-row mb-3">
|
||
<div class="quiet text-body-2">Identification photos — what the asset looks like, where it is, nameplates, serial labels.</div>
|
||
<v-spacer />
|
||
<v-btn variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.photoInput.click()">Add photos</v-btn>
|
||
<input ref="photoInput" hidden type="file" accept="image/*" capture="environment" multiple @change="onPhotoPick" />
|
||
</div>
|
||
<div v-if="assetPhotos.length" class="asset-photo-grid">
|
||
<div v-for="photo in assetPhotos" :key="photo.id" class="asset-photo-card">
|
||
<div class="asset-photo-frame">
|
||
<img :src="photo.data" :alt="photo.caption || photo.name || 'asset photo'" />
|
||
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="asset-photo-remove" @click="$emit('delete-photo', photo.id)" />
|
||
</div>
|
||
<v-text-field
|
||
:model-value="photo.caption"
|
||
density="compact"
|
||
hide-details
|
||
placeholder="Caption"
|
||
class="mt-1"
|
||
@update:model-value="captionDrafts[photo.id] = $event"
|
||
@blur="saveCaption(photo)"
|
||
@keyup.enter="saveCaption(photo)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p v-else class="quiet text-body-2">No photos yet. On a phone, "Add photos" opens the camera.</p>
|
||
</template>
|
||
<p v-else class="quiet text-body-2">Save the asset first, then add photos.</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>
|
||
|
||
<!-- DISPOSAL -->
|
||
<v-window-item value="disposal">
|
||
<template v-if="localAsset.id">
|
||
<v-list v-if="assetDisposals.length" lines="two" density="compact" class="mb-3">
|
||
<v-list-item v-for="d in assetDisposals" :key="d.id" prepend-icon="mdi-cash-remove">
|
||
<v-list-item-title>
|
||
{{ d.partial ? 'Partial' : 'Full' }} {{ d.method }} · {{ d.disposal_date }}
|
||
<span :class="d.gain_loss >= 0 ? 'text-success' : 'text-error'">
|
||
{{ d.gain_loss >= 0 ? 'Gain' : 'Loss' }} {{ currency(Math.abs(d.gain_loss)) }}
|
||
</span>
|
||
</v-list-item-title>
|
||
<v-list-item-subtitle>
|
||
{{ d.book_type }} · proceeds {{ currency(d.disposal_price - d.disposal_expense) }} · NBV {{ currency(d.net_book_value) }}
|
||
</v-list-item-subtitle>
|
||
<template #append>
|
||
<v-btn icon="mdi-undo" size="small" variant="text" @click="$emit('reverse-disposal', d.id)" />
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
|
||
<h3 class="section-title mb-2">Record disposal</h3>
|
||
<div class="form-grid">
|
||
<v-select v-model="disposalForm.book_type" :items="bookItems" label="Book" />
|
||
<v-select v-model="disposalForm.method" :items="disposalMethodItems" item-title="title" item-value="value" label="Disposal type" />
|
||
<v-text-field v-model="disposalForm.disposal_date" type="date" label="Disposal date" />
|
||
<v-select v-model="disposalForm.property_type" :items="propertyTypeItems" item-title="title" item-value="value" label="Property type (Form 4797)" />
|
||
<v-text-field v-model.number="disposalForm.disposal_price" type="number" label="Sale price" />
|
||
<v-text-field v-model.number="disposalForm.disposal_expense" type="number" label="Sale expense" />
|
||
<v-switch v-model="disposalForm.partial" label="Partial disposal" color="primary" density="compact" hide-details class="full" />
|
||
<v-text-field v-if="disposalForm.partial" v-model.number="disposalForm.disposed_cost" type="number" label="Cost basis disposed" class="full" />
|
||
<v-textarea v-model="disposalForm.notes" class="full" label="Notes" rows="2" />
|
||
</div>
|
||
<div class="toolbar-row mt-2">
|
||
<v-btn variant="tonal" prepend-icon="mdi-calculator" @click="$emit('preview-disposal', { ...disposalForm })">Calculate gain/loss</v-btn>
|
||
<v-spacer />
|
||
<v-btn color="error" prepend-icon="mdi-cash-remove" @click="$emit('record-disposal', { ...disposalForm })">Record disposal</v-btn>
|
||
</div>
|
||
|
||
<v-alert v-if="disposalPreview" class="mt-3" density="compact" :type="disposalPreview.gain_loss >= 0 ? 'success' : 'warning'">
|
||
Accumulated depreciation {{ currency(disposalPreview.accumulated_depreciation) }} ·
|
||
Net book value {{ currency(disposalPreview.net_book_value) }} ·
|
||
Proceeds {{ currency(disposalPreview.realized) }} →
|
||
<strong>{{ disposalPreview.gain_loss >= 0 ? 'Gain' : 'Loss' }} of {{ currency(Math.abs(disposalPreview.gain_loss)) }}</strong>
|
||
</v-alert>
|
||
|
||
<v-divider class="my-4" />
|
||
<h3 class="section-title mb-2">Impairments & adjustments</h3>
|
||
<v-list v-if="assetAdjustments.length" density="compact" class="mb-2">
|
||
<v-list-item v-for="adj in assetAdjustments" :key="adj.id" prepend-icon="mdi-trending-down">
|
||
<v-list-item-title>{{ adj.type }} · {{ currency(adj.amount) }} ({{ adj.book_type }})</v-list-item-title>
|
||
<v-list-item-subtitle>{{ adj.adjustment_date }} · {{ adj.reason || '—' }}</v-list-item-subtitle>
|
||
<template #append>
|
||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('delete-adjustment', adj.id)" />
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
<div class="form-grid">
|
||
<v-select v-model="adjustmentForm.type" :items="adjustmentTypeItems" item-title="title" item-value="value" label="Type" />
|
||
<v-select v-model="adjustmentForm.book_type" :items="bookItems" label="Book" />
|
||
<v-text-field v-model.number="adjustmentForm.amount" type="number" label="Amount" />
|
||
<v-text-field v-model="adjustmentForm.adjustment_date" type="date" label="Date" />
|
||
<v-text-field v-model="adjustmentForm.reason" class="full" label="Reason" />
|
||
</div>
|
||
<v-btn variant="tonal" prepend-icon="mdi-plus" class="mt-2" :disabled="!adjustmentForm.amount" @click="addAdjustment">Add adjustment</v-btn>
|
||
</template>
|
||
<p v-else class="quiet text-body-2">Save the asset first, then record a disposal.</p>
|
||
</v-window-item>
|
||
|
||
<!-- LEASE -->
|
||
<v-window-item value="lease">
|
||
<template v-if="localAsset.id">
|
||
<v-list v-if="assetLeases.length" lines="two" density="compact" class="mb-3">
|
||
<v-list-item
|
||
v-for="lease in assetLeases"
|
||
:key="lease.id"
|
||
:active="selectedLeaseId === lease.id"
|
||
prepend-icon="mdi-file-document-outline"
|
||
@click="$emit('load-lease-schedule', lease.id)"
|
||
>
|
||
<v-list-item-title>{{ lease.lessor || 'Lease' }} · {{ currency(lease.contract_value) }}</v-list-item-title>
|
||
<v-list-item-subtitle>{{ lease.start_date }} → {{ lease.end_date }} · {{ lease.payment_frequency }} · {{ lease.discount_rate }}%</v-list-item-subtitle>
|
||
<template #append>
|
||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click.stop="$emit('delete-lease', lease.id)" />
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
|
||
<h3 class="section-title mb-2">Add lease</h3>
|
||
<div class="form-grid">
|
||
<v-text-field v-model="leaseForm.lessor" label="Lessor" />
|
||
<v-text-field v-model.number="leaseForm.contract_value" type="number" label="Contract value (total payments)" />
|
||
<v-text-field v-model="leaseForm.start_date" type="date" label="Start date" />
|
||
<v-text-field v-model="leaseForm.end_date" type="date" label="End date" />
|
||
<v-text-field v-model.number="leaseForm.discount_rate" type="number" label="Discount rate %" />
|
||
<v-select v-model="leaseForm.payment_frequency" :items="leaseFrequencyItems" item-title="title" item-value="value" label="Payment frequency" />
|
||
<v-textarea v-model="leaseForm.notes" class="full" label="Notes" rows="2" />
|
||
</div>
|
||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" @click="addLease">Add lease</v-btn>
|
||
|
||
<template v-if="leaseSchedule">
|
||
<v-divider class="my-4" />
|
||
<h3 class="section-title mb-1">Amortization schedule</h3>
|
||
<p class="quiet text-body-2 mb-2">
|
||
ROU asset / present value {{ currency(leaseSchedule.summary.present_value) }} ·
|
||
{{ leaseSchedule.summary.periods }} payments of {{ currency(leaseSchedule.summary.periodic_payment) }} ·
|
||
interest {{ currency(leaseSchedule.summary.total_interest) }}
|
||
</p>
|
||
<div style="overflow-x:auto;max-height:280px">
|
||
<table class="asset-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th><th>Date</th><th>Payment</th><th>Interest</th><th>Principal</th><th>Liability</th><th>ROU bal.</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in leaseSchedule.rows" :key="row.period">
|
||
<td>{{ row.period }}</td>
|
||
<td>{{ row.date }}</td>
|
||
<td>{{ currency(row.payment) }}</td>
|
||
<td>{{ currency(row.interest) }}</td>
|
||
<td>{{ currency(row.principal) }}</td>
|
||
<td>{{ currency(row.liability_balance) }}</td>
|
||
<td>{{ currency(row.rou_balance) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
<p v-else class="quiet text-body-2">Save the asset first, then capture a lease.</p>
|
||
</v-window-item>
|
||
|
||
<!-- PREVENTATIVE MAINTENANCE -->
|
||
<v-window-item value="pm">
|
||
<template v-if="localAsset.id">
|
||
<v-list v-if="assetPm.length" lines="two" density="compact" class="mb-3">
|
||
<v-list-item v-for="pm in assetPm" :key="pm.id" :prepend-icon="pm.active ? 'mdi-wrench-clock' : 'mdi-check-circle-outline'">
|
||
<v-list-item-title>{{ pm.plan_name || 'Maintenance' }} · every {{ pm.frequency_value }} {{ unitLabel(pm.frequency_unit) }}</v-list-item-title>
|
||
<v-list-item-subtitle>
|
||
<template v-if="pm.active">
|
||
Next due {{ pm.next_due_date || '—' }}
|
||
<v-chip v-if="dueDriver(pm)" size="x-small" variant="tonal" :color="dueDriver(pm).color" class="ml-1">
|
||
<v-icon :icon="dueDriver(pm).icon" size="12" start /> {{ dueDriver(pm).label }}
|
||
</v-chip>
|
||
<template v-if="pm.last_completed_date"> · last done {{ pm.last_completed_date }}</template>
|
||
</template>
|
||
<template v-else>Schedule complete (past {{ pm.end_date }})</template>
|
||
<template v-if="pm.total_cost"> · spent {{ currency(pm.total_cost) }}</template>
|
||
<div v-for="m in pm.meters" :key="m.id" class="text-caption">
|
||
{{ m.label }}: {{ m.last_reading != null ? Math.round(m.last_reading) : '—' }}<template v-if="m.due_at_reading"> / {{ Math.round(m.due_at_reading) }}</template>
|
||
<span v-if="m.unit"> {{ m.unit }}</span>
|
||
<span v-if="m.projected_usage_due_date" class="quiet"> · projected {{ m.projected_usage_due_date }}</span>
|
||
</div>
|
||
</v-list-item-subtitle>
|
||
<template #append>
|
||
<v-btn v-if="pm.active && pm.meters && pm.meters.length" size="small" variant="text" @click="openLogReading(pm)">Log reading</v-btn>
|
||
<v-btn v-if="pm.active" size="small" variant="text" color="primary" @click="openComplete(pm)">Complete</v-btn>
|
||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('detach-pm', pm.id)" />
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
|
||
<template v-if="pmCompletions.length">
|
||
<h3 class="section-title mb-2">Completed forms</h3>
|
||
<v-list density="compact" class="mb-3">
|
||
<v-list-item v-for="c in pmCompletions" :key="c.id" prepend-icon="mdi-clipboard-check-outline">
|
||
<v-list-item-title>{{ c.plan_name || 'Maintenance' }} · {{ (c.completed_at || '').slice(0, 10) }}</v-list-item-title>
|
||
<v-list-item-subtitle>
|
||
by {{ c.completed_by_name || 'Unknown' }} · safety {{ c.ratings.safety || '–' }}★
|
||
<template v-if="c.photo_count"> · {{ c.photo_count }} photo(s)</template>
|
||
<template v-if="c.has_signature"> · signed</template>
|
||
</v-list-item-subtitle>
|
||
<template #append>
|
||
<v-btn size="small" variant="text" @click="$emit('view-completion', c.id)">View</v-btn>
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
<v-divider class="mb-3" />
|
||
</template>
|
||
|
||
<v-divider class="mb-3" />
|
||
<h3 class="section-title mb-2">Attach a PM plan</h3>
|
||
<div class="form-grid">
|
||
<v-select v-model="pmForm.plan_id" :items="planOptions" item-title="title" item-value="value" label="Plan" class="full" @update:model-value="applyPlanDefaults" />
|
||
<v-text-field v-model.number="pmForm.frequency_value" type="number" label="Every" />
|
||
<v-select v-model="pmForm.frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Frequency unit" />
|
||
<v-text-field v-model="pmForm.start_date" type="date" label="Start / first due" />
|
||
<v-text-field v-model="pmForm.end_date" type="date" label="End date (depreciation end)" />
|
||
</div>
|
||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" :disabled="!pmForm.plan_id" @click="attachPm">Attach plan</v-btn>
|
||
</template>
|
||
<p v-else class="quiet text-body-2">Save the asset first, then attach PM plans.</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">
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
|
||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="attemptSave">Save asset</v-btn>
|
||
</div>
|
||
</div>
|
||
|
||
<v-dialog v-model="completeDialog" max-width="620" scrollable>
|
||
<v-card>
|
||
<v-card-title>Complete: {{ completeTarget?.plan_name || 'Maintenance' }}</v-card-title>
|
||
<v-card-text>
|
||
<div class="quiet text-body-2 mb-3">Closing as <strong>{{ currentUserName || 'current user' }}</strong></div>
|
||
|
||
<template v-if="completeGuidancePhotos.length">
|
||
<h3 class="section-title mb-1">Guidance photos</h3>
|
||
<div class="pm-guidance-grid mb-3">
|
||
<figure v-for="photo in completeGuidancePhotos" :key="photo.id" class="pm-guidance-fig">
|
||
<img :src="photo.data" :alt="photo.caption || 'guidance'" />
|
||
<figcaption v-if="photo.caption" class="quiet text-caption">{{ photo.caption }}</figcaption>
|
||
</figure>
|
||
</div>
|
||
</template>
|
||
|
||
<h3 class="section-title mb-1">Steps</h3>
|
||
<p v-if="!completeSteps.length" class="quiet text-body-2">This plan has no steps.</p>
|
||
<div v-for="(step, i) in completeSteps" :key="i" class="d-flex align-start mb-1">
|
||
<v-checkbox-btn v-model="step.done" />
|
||
<div class="mt-1">
|
||
<div>{{ step.title }}</div>
|
||
<div v-if="step.details" class="quiet text-caption">{{ step.details }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<v-divider class="my-3" />
|
||
<h3 class="section-title mb-2">Condition ratings</h3>
|
||
<div v-for="q in ratingQuestions" :key="q.key" class="d-flex align-center mb-1">
|
||
<div class="text-body-2" style="min-width:230px">{{ q.label }}</div>
|
||
<v-rating v-model="completeRatings[q.key]" length="5" color="amber" active-color="amber" density="compact" hover />
|
||
</div>
|
||
|
||
<template v-if="completeComponents.length">
|
||
<v-divider class="my-3" />
|
||
<div class="text-body-2 font-weight-medium mb-1">Parts used</div>
|
||
<div v-for="(c, i) in completeComponents" :key="`cc${i}`" class="d-flex text-body-2 mb-1">
|
||
<span>{{ c.part_number ? c.part_number + ' · ' : '' }}{{ c.description }}<span v-if="c.supplier" class="quiet"> ({{ c.supplier }})</span></span>
|
||
<v-spacer />
|
||
<span>{{ currency(c.cost) }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<v-text-field v-model.number="completeCost" type="number" prefix="$" label="Service cost" density="compact" hide-details class="mt-3" />
|
||
|
||
<template v-if="completeMeters.length">
|
||
<v-divider class="my-3" />
|
||
<h3 class="section-title mb-1">Usage meter readings</h3>
|
||
<p class="quiet text-caption mb-2">Record the current reading; the next service threshold resets from here.</p>
|
||
<div v-for="(m, i) in completeMeters" :key="`m${i}`" class="d-flex align-center mb-2" style="gap:10px">
|
||
<div class="text-body-2" style="min-width:180px">
|
||
{{ m.label }}<span v-if="m.unit" class="quiet"> ({{ m.unit }})</span>
|
||
<div v-if="m.last_reading != null" class="quiet text-caption">last {{ Math.round(m.last_reading) }}<template v-if="m.due_at_reading"> · due at {{ Math.round(m.due_at_reading) }}</template></div>
|
||
</div>
|
||
<v-text-field v-model.number="m.reading" type="number" density="compact" hide-details placeholder="Current reading" :suffix="m.unit || ''" />
|
||
</div>
|
||
</template>
|
||
|
||
<v-divider class="my-3" />
|
||
<div class="toolbar-row mb-1">
|
||
<h3 class="section-title">Completion notes</h3>
|
||
<v-spacer />
|
||
<v-btn size="x-small" variant="tonal" prepend-icon="mdi-plus" @click="completeNotesList.push('')">Add note</v-btn>
|
||
</div>
|
||
<div v-for="(note, i) in completeNotesList" :key="`note${i}`" class="d-flex align-center mb-1" style="gap:6px">
|
||
<v-text-field v-model="completeNotesList[i]" density="compact" hide-details placeholder="Note" />
|
||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="completeNotesList.splice(i, 1)" />
|
||
</div>
|
||
|
||
<v-divider class="my-3" />
|
||
<div class="toolbar-row mb-1">
|
||
<h3 class="section-title">Photos</h3>
|
||
<v-spacer />
|
||
<v-btn size="x-small" variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.pmPhotoInput.click()">Add photos</v-btn>
|
||
<input ref="pmPhotoInput" hidden type="file" accept="image/*" multiple @change="onCompletePhotos" />
|
||
</div>
|
||
<div v-if="completePhotos.length" class="pm-photo-grid">
|
||
<div v-for="(p, i) in completePhotos" :key="`p${i}`" class="pm-photo-wrap">
|
||
<img :src="p.data" class="pm-photo-thumb" :alt="p.name || 'maintenance photo'" />
|
||
<v-btn icon="mdi-close" size="x-small" class="pm-photo-remove" @click="completePhotos.splice(i, 1)" />
|
||
</div>
|
||
</div>
|
||
|
||
<v-divider class="my-3" />
|
||
<h3 class="section-title mb-1">Signature <span class="text-error">*</span></h3>
|
||
<SignaturePad v-model="completeSignature" />
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="completeDialog = false">Cancel</v-btn>
|
||
<v-btn color="primary" prepend-icon="mdi-check" :disabled="!completeSignature" @click="submitComplete">Mark complete</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- Quick usage-meter reading (recomputes the schedule without completing a service) -->
|
||
<v-dialog v-model="logDialog" max-width="420">
|
||
<v-card v-if="logTarget">
|
||
<v-card-title>Log usage reading</v-card-title>
|
||
<v-card-text>
|
||
<div class="quiet text-body-2 mb-3">{{ logTarget.plan_name || 'Maintenance' }}</div>
|
||
<v-select
|
||
v-if="(logTarget.meters || []).length > 1"
|
||
v-model="logForm.asset_pm_meter_id"
|
||
:items="(logTarget.meters || []).map((m) => ({ title: m.label, value: m.id }))"
|
||
item-title="title"
|
||
item-value="value"
|
||
label="Meter"
|
||
density="compact"
|
||
/>
|
||
<v-text-field v-model.number="logForm.reading" type="number" label="Current reading" density="compact" />
|
||
<v-text-field v-model="logForm.reading_date" type="date" label="Reading date" density="compact" />
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="logDialog = false">Cancel</v-btn>
|
||
<v-btn color="primary" prepend-icon="mdi-check" :disabled="logForm.reading === '' || logForm.reading == null || !logForm.asset_pm_meter_id" @click="submitLogReading">Save reading</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- Prompt shown when depreciation-critical fields change on an already-depreciated asset -->
|
||
<v-dialog v-model="applyDialog" max-width="560">
|
||
<v-card>
|
||
<v-card-title>Apply depreciation change</v-card-title>
|
||
<v-card-text>
|
||
<p class="mb-2">You changed depreciation-critical fields ({{ changedCriticalLabels }}).</p>
|
||
<p class="mb-3 quiet text-body-2">Choose when the change takes effect:</p>
|
||
<v-radio-group v-model="applyChoice" hide-details>
|
||
<v-radio value="placed_in_service">
|
||
<template #label>
|
||
<div>
|
||
<div>Placed-in-service date</div>
|
||
<div class="quiet text-caption">Rebuild the entire depreciation history from the start (clears prior depreciation).</div>
|
||
</div>
|
||
</template>
|
||
</v-radio>
|
||
<v-radio value="going_forward" class="mt-2">
|
||
<template #label>
|
||
<div>
|
||
<div>Current period — going forward</div>
|
||
<div class="quiet text-caption">Keep depreciation already recorded; apply the change to remaining periods.</div>
|
||
</div>
|
||
</template>
|
||
</v-radio>
|
||
</v-radio-group>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="applyDialog = false">Cancel</v-btn>
|
||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="confirmApply">Save asset</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</v-navigation-drawer>
|
||
</template>
|
||
|
||
<script>
|
||
import BarcodeLabel from './BarcodeLabel.vue';
|
||
import SignaturePad from './SignaturePad.vue';
|
||
import { inputTypeForCustomField, makeDefaultBooks } from '../utils/assets';
|
||
import { clonePlain } from '../utils/clone';
|
||
import { currency } from '../utils/format';
|
||
import {
|
||
adjustmentTypeItems,
|
||
conditionItems,
|
||
conventionItems,
|
||
disposalMethodItems,
|
||
leaseFrequencyItems,
|
||
newUsedItems,
|
||
pmFrequencyUnits,
|
||
propertyTypeItems,
|
||
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: '' };
|
||
}
|
||
|
||
function blankDisposal() {
|
||
return {
|
||
book_type: 'GAAP',
|
||
method: 'sale',
|
||
disposal_date: new Date().toISOString().slice(0, 10),
|
||
property_type: '1245',
|
||
disposal_price: 0,
|
||
disposal_expense: 0,
|
||
partial: false,
|
||
disposed_cost: 0,
|
||
notes: ''
|
||
};
|
||
}
|
||
|
||
function blankAdjustment() {
|
||
return { type: 'impairment', book_type: 'GAAP', amount: 0, adjustment_date: new Date().toISOString().slice(0, 10), reason: '' };
|
||
}
|
||
|
||
function blankLease() {
|
||
return { lessor: '', contract_value: 0, start_date: '', end_date: '', discount_rate: 0, payment_frequency: 'monthly', notes: '' };
|
||
}
|
||
|
||
export default {
|
||
components: { BarcodeLabel, SignaturePad },
|
||
props: {
|
||
assetForm: { type: Object, required: true },
|
||
assetAdjustments: { type: Array, default: () => [] },
|
||
assetDisposals: { type: Array, default: () => [] },
|
||
assetFiles: { type: Array, default: () => [] },
|
||
assetLeases: { type: Array, default: () => [] },
|
||
assetNotes: { type: Array, default: () => [] },
|
||
assetPhotos: { type: Array, default: () => [] },
|
||
assetPm: { type: Array, default: () => [] },
|
||
assetTasks: { type: Array, default: () => [] },
|
||
assetWarranties: { type: Array, default: () => [] },
|
||
bookItems: { type: Array, default: () => [] },
|
||
pmPlans: { type: Array, default: () => [] },
|
||
pmDefaults: { type: Object, default: () => ({ value: 3, unit: 'months' }) },
|
||
currentUserName: { type: String, default: '' },
|
||
categoryItems: { type: Array, required: true },
|
||
disposalPreview: { type: Object, default: null },
|
||
drawerError: { type: String, default: '' },
|
||
entityOptions: { type: Array, required: true },
|
||
locationItems: { type: Array, default: () => [] },
|
||
departmentItems: { type: Array, default: () => [] },
|
||
vendorItems: { type: Array, default: () => [] },
|
||
categoryClassNumbers: { type: Object, default: () => ({}) },
|
||
zoneItems: { type: Array, default: () => [] },
|
||
zoneDetails: { type: Object, default: () => ({}) },
|
||
leaseSchedule: { type: Object, default: null },
|
||
methodItems: { type: Array, required: true },
|
||
modelValue: { type: Boolean, default: false },
|
||
saving: { type: Boolean, default: false },
|
||
selectedLeaseId: { type: [Number, String], default: null },
|
||
selectedTemplateFields: { type: Array, required: true },
|
||
selectedTemplateId: { type: [Number, String], default: null },
|
||
statusItems: { type: Array, required: true },
|
||
templateOptions: { type: Array, required: true }
|
||
},
|
||
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',
|
||
'upload-photo', 'update-photo', 'delete-photo',
|
||
'preview-disposal', 'record-disposal', 'reverse-disposal',
|
||
'add-adjustment', 'delete-adjustment',
|
||
'save-lease', 'delete-lease', 'load-lease-schedule',
|
||
'print-label',
|
||
'attach-pm', 'detach-pm', 'complete-pm', 'log-reading', 'view-completion'
|
||
],
|
||
data() {
|
||
return {
|
||
tab: 'details',
|
||
adjustmentTypeItems,
|
||
conditionItems,
|
||
conventionItems,
|
||
disposalMethodItems,
|
||
leaseFrequencyItems,
|
||
newUsedItems,
|
||
pmFrequencyUnits,
|
||
propertyTypeItems,
|
||
taskTypeItems,
|
||
localAsset: this.normalize(this.assetForm),
|
||
warrantyForm: blankWarranty(),
|
||
taskForm: blankTask(),
|
||
disposalForm: blankDisposal(),
|
||
adjustmentForm: blankAdjustment(),
|
||
leaseForm: blankLease(),
|
||
pmForm: { plan_id: null, frequency_value: 3, frequency_unit: 'months', start_date: '', end_date: '' },
|
||
completeDialog: false,
|
||
completeTarget: null,
|
||
completeSteps: [],
|
||
completeGuidancePhotos: [],
|
||
completeComponents: [],
|
||
completeCost: 0,
|
||
completeNotesList: [],
|
||
completeRatings: { safety: 0, physical: 0, operating: 0 },
|
||
completeSignature: '',
|
||
completePhotos: [],
|
||
completeMeters: [],
|
||
logDialog: false,
|
||
logTarget: null,
|
||
logForm: { asset_pm_meter_id: null, reading: '', reading_date: new Date().toISOString().slice(0, 10) },
|
||
ratingQuestions: [
|
||
{ key: 'safety', label: 'Safety condition / Is it safe?' },
|
||
{ key: 'physical', label: 'Physical condition / appearance' },
|
||
{ key: 'operating', label: 'Operating condition / functionality' }
|
||
],
|
||
noteDraft: '',
|
||
captionDrafts: {},
|
||
criticalSnapshot: null,
|
||
applyDialog: false,
|
||
applyChoice: 'placed_in_service',
|
||
requiredRule: (value) => value !== null && value !== undefined && value !== '' || 'Required'
|
||
};
|
||
},
|
||
computed: {
|
||
barcodeValue() {
|
||
return (this.localAsset.barcode_value || this.localAsset.asset_id || '').trim();
|
||
},
|
||
assetClassNumber() {
|
||
// Live from the selected category; fall back to the value stored on the asset.
|
||
return this.categoryClassNumbers[this.localAsset.category] || this.localAsset.asset_class_number || '';
|
||
},
|
||
zoneGuidance() {
|
||
const zone = this.zoneDetails[this.localAsset.special_zone];
|
||
if (!zone) return '';
|
||
const pis = this.localAsset.in_service_date || this.localAsset.acquired_date || '';
|
||
const inWindow = (!zone.pis_start || !pis || pis >= zone.pis_start) && (!zone.pis_end || !pis || pis <= zone.pis_end);
|
||
const benefits = [];
|
||
if (Number(zone.allowance_percent) > 0) benefits.push(`a ${zone.allowance_percent}% §168 special allowance`);
|
||
if (Number(zone.section_179_increase) > 0) {
|
||
const factor = Number(zone.section_179_cost_factor);
|
||
const phaseout = factor && factor !== 1 ? ` (only ${Math.round(factor * 100)}% of cost counts toward the §179 phaseout)` : '';
|
||
benefits.push(`an increased §179 limit of +$${Number(zone.section_179_increase).toLocaleString()}${phaseout}`);
|
||
}
|
||
if (!benefits.length) benefits.push('special depreciation treatment');
|
||
const window = zone.pis_start || zone.pis_end ? ` for assets placed in service ${zone.pis_start || '…'} to ${zone.pis_end || '…'}` : '';
|
||
const base = `${zone.name}: ${benefits.join(' and ')} on the FEDERAL book${window}.`;
|
||
const lease = zone.leasehold_life_years ? ` Qualified leasehold improvements use a ${zone.leasehold_life_years}-year life.` : '';
|
||
if (pis && !inWindow) return `${base} This asset's placed-in-service date is OUTSIDE the window, so it will not apply.${lease}`;
|
||
return `${base}${lease}`;
|
||
},
|
||
changedCriticalLabels() {
|
||
if (!this.criticalSnapshot) return 'depreciation settings';
|
||
const current = this.criticalObject(this.localAsset);
|
||
const snap = this.criticalSnapshot;
|
||
const labels = {
|
||
property_type: 'property type',
|
||
in_service_date: 'placed-in-service date',
|
||
acquisition_cost: 'acquisition value',
|
||
useful_life_months: 'estimated life',
|
||
salvage_value: 'salvage value',
|
||
business_use_percent: 'business-use %',
|
||
depreciation_method: 'depreciation method',
|
||
special_zone: 'special depreciation zone'
|
||
};
|
||
const changed = Object.keys(labels).filter((key) => JSON.stringify(current[key]) !== JSON.stringify(snap[key])).map((key) => labels[key]);
|
||
if (JSON.stringify(current.books) !== JSON.stringify(snap.books)) changed.push('book settings (method, life, §179, §168, cost, or business-use)');
|
||
return changed.join(', ') || 'depreciation settings';
|
||
},
|
||
planOptions() {
|
||
return this.pmPlans.map((plan) => ({
|
||
title: `${plan.name} (every ${plan.frequency_value} ${this.unitLabel(plan.frequency_unit)})`,
|
||
value: plan.id
|
||
}));
|
||
},
|
||
depreciationEnd() {
|
||
const start = this.localAsset.in_service_date;
|
||
const months = Number(this.localAsset.useful_life_months) || 0;
|
||
if (!start || !months) return '';
|
||
const date = new Date(`${start}T00:00:00`);
|
||
date.setMonth(date.getMonth() + months);
|
||
return date.toISOString().slice(0, 10);
|
||
},
|
||
pmCompletions() {
|
||
return (this.assetPm || [])
|
||
.flatMap((pm) => (pm.completions || []).map((c) => ({ ...c, plan_name: pm.plan_name })))
|
||
.sort((a, b) => String(b.completed_at).localeCompare(String(a.completed_at)));
|
||
}
|
||
},
|
||
watch: {
|
||
assetForm: {
|
||
handler(value) {
|
||
this.localAsset = this.normalize(value);
|
||
this.criticalSnapshot = this.criticalObject(this.localAsset);
|
||
},
|
||
deep: true
|
||
},
|
||
modelValue(open) {
|
||
if (open) {
|
||
this.tab = 'details';
|
||
this.warrantyForm = blankWarranty();
|
||
this.taskForm = blankTask();
|
||
this.disposalForm = blankDisposal();
|
||
this.adjustmentForm = blankAdjustment();
|
||
this.leaseForm = blankLease();
|
||
this.resetPmForm();
|
||
this.noteDraft = '';
|
||
this.criticalSnapshot = this.criticalObject(this.localAsset);
|
||
}
|
||
}
|
||
},
|
||
methods: {
|
||
inputTypeForCustomField,
|
||
currency,
|
||
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;
|
||
},
|
||
// Snapshot of the depreciation-critical fields, used to detect changes on save.
|
||
criticalObject(asset) {
|
||
return {
|
||
property_type: asset.property_type ?? null,
|
||
in_service_date: asset.in_service_date ?? null,
|
||
acquisition_cost: Number(asset.acquisition_cost || 0),
|
||
useful_life_months: Number(asset.useful_life_months || 0),
|
||
salvage_value: Number(asset.salvage_value || 0),
|
||
business_use_percent: Number(asset.business_use_percent || 0),
|
||
listed_property: Boolean(asset.listed_property),
|
||
passenger_auto: Boolean(asset.passenger_auto),
|
||
depreciation_method: asset.depreciation_method ?? null,
|
||
special_zone: asset.special_zone ?? null,
|
||
books: (asset.books || []).map((b) => ({
|
||
book_type: b.book_type,
|
||
cost: b.cost ?? null,
|
||
depreciation_method: b.depreciation_method ?? null,
|
||
useful_life_months: b.useful_life_months ?? null,
|
||
section_179_amount: Number(b.section_179_amount || 0),
|
||
bonus_percent: Number(b.bonus_percent || 0),
|
||
business_use_percent: b.business_use_percent ?? null,
|
||
convention: b.convention ?? null,
|
||
active: b.active !== false
|
||
}))
|
||
};
|
||
},
|
||
hasCriticalChange() {
|
||
return this.criticalSnapshot && JSON.stringify(this.criticalObject(this.localAsset)) !== JSON.stringify(this.criticalSnapshot);
|
||
},
|
||
attemptSave() {
|
||
// Existing, already-saved asset whose depreciation inputs changed → ask when to apply.
|
||
if (this.localAsset.id && this.hasCriticalChange()) {
|
||
this.applyChoice = 'placed_in_service';
|
||
this.applyDialog = true;
|
||
} else {
|
||
this.$emit('save-asset', this.localAsset);
|
||
}
|
||
},
|
||
confirmApply() {
|
||
this.$emit('save-asset', { ...this.localAsset, depreciation_apply: this.applyChoice });
|
||
this.applyDialog = false;
|
||
},
|
||
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 = '';
|
||
},
|
||
onPhotoPick(event) {
|
||
for (const file of Array.from(event.target.files || [])) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => this.$emit('upload-photo', { name: file.name, mime: file.type, data: String(reader.result) });
|
||
reader.readAsDataURL(file);
|
||
}
|
||
event.target.value = '';
|
||
},
|
||
saveCaption(photo) {
|
||
const caption = this.captionDrafts[photo.id];
|
||
if (caption === undefined || caption === photo.caption) return;
|
||
this.$emit('update-photo', { id: photo.id, caption });
|
||
},
|
||
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 = '';
|
||
},
|
||
addAdjustment() {
|
||
this.$emit('add-adjustment', { ...this.adjustmentForm });
|
||
this.adjustmentForm = blankAdjustment();
|
||
},
|
||
addLease() {
|
||
this.$emit('save-lease', { ...this.leaseForm });
|
||
this.leaseForm = blankLease();
|
||
},
|
||
useAssetIdAsBarcode() {
|
||
this.localAsset.barcode_value = this.localAsset.asset_id || '';
|
||
},
|
||
unitLabel(unit) {
|
||
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
|
||
},
|
||
// Which signal drove the next-due date (only meaningful once usage/lifespan are in play).
|
||
dueDriver(pm) {
|
||
const map = {
|
||
usage: { label: 'usage', icon: 'mdi-gauge', color: 'info' },
|
||
lifespan: { label: 'age', icon: 'mdi-chart-bell-curve-cumulative', color: 'warning' },
|
||
calendar: { label: 'date', icon: 'mdi-calendar', color: undefined }
|
||
};
|
||
// Only badge when a dynamic signal is active (meters present or lifespan compression applied).
|
||
const hasMeters = pm.meters && pm.meters.length;
|
||
const compressed = pm.signals && pm.signals.factor != null && pm.signals.factor < 1;
|
||
if (!hasMeters && !compressed) return null;
|
||
return map[pm.due_driver] || null;
|
||
},
|
||
resetPmForm() {
|
||
this.pmForm = {
|
||
plan_id: null,
|
||
frequency_value: this.pmDefaults?.value || 3,
|
||
frequency_unit: this.pmDefaults?.unit || 'months',
|
||
start_date: new Date().toISOString().slice(0, 10),
|
||
end_date: this.depreciationEnd
|
||
};
|
||
},
|
||
applyPlanDefaults(planId) {
|
||
const plan = this.pmPlans.find((p) => p.id === planId);
|
||
if (plan) {
|
||
this.pmForm.frequency_value = plan.frequency_value;
|
||
this.pmForm.frequency_unit = plan.frequency_unit;
|
||
}
|
||
},
|
||
attachPm() {
|
||
this.$emit('attach-pm', { ...this.pmForm, next_due_date: this.pmForm.start_date });
|
||
this.resetPmForm();
|
||
},
|
||
openComplete(pm) {
|
||
this.completeTarget = pm;
|
||
this.completeSteps = (pm.steps || []).map((s) => ({ title: s.title, details: s.details, done: false }));
|
||
this.completeGuidancePhotos = pm.plan_photos || [];
|
||
this.completeComponents = (pm.components || []).map((c) => ({ ...c }));
|
||
this.completeCost = this.completeComponents.reduce((sum, c) => sum + (Number(c.cost) || 0), 0);
|
||
this.completeNotesList = [''];
|
||
this.completeRatings = { safety: 0, physical: 0, operating: 0 };
|
||
this.completeSignature = '';
|
||
this.completePhotos = [];
|
||
this.completeMeters = (pm.meters || []).map((m) => ({
|
||
asset_pm_meter_id: m.id, label: m.label, unit: m.unit,
|
||
last_reading: m.last_reading, due_at_reading: m.due_at_reading, reading: ''
|
||
}));
|
||
this.completeDialog = true;
|
||
},
|
||
openLogReading(pm) {
|
||
this.logTarget = pm;
|
||
this.logForm = {
|
||
asset_pm_meter_id: (pm.meters && pm.meters[0]) ? pm.meters[0].id : null,
|
||
reading: '',
|
||
reading_date: new Date().toISOString().slice(0, 10)
|
||
};
|
||
this.logDialog = true;
|
||
},
|
||
submitLogReading() {
|
||
this.$emit('log-reading', {
|
||
apId: this.logTarget.id,
|
||
asset_pm_meter_id: this.logForm.asset_pm_meter_id,
|
||
reading: Number(this.logForm.reading),
|
||
reading_date: this.logForm.reading_date,
|
||
source: 'manual'
|
||
});
|
||
this.logDialog = false;
|
||
},
|
||
onCompletePhotos(event) {
|
||
for (const file of Array.from(event.target.files || [])) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => this.completePhotos.push({ name: file.name, mime: file.type, data: String(reader.result) });
|
||
reader.readAsDataURL(file);
|
||
}
|
||
event.target.value = '';
|
||
},
|
||
submitComplete() {
|
||
this.$emit('complete-pm', {
|
||
apId: this.completeTarget.id,
|
||
steps: this.completeSteps.map((s) => ({ title: s.title, done: s.done })),
|
||
components: this.completeComponents,
|
||
cost: this.completeCost,
|
||
notes_list: this.completeNotesList.map((n) => String(n || '').trim()).filter(Boolean),
|
||
ratings: { ...this.completeRatings },
|
||
signature: this.completeSignature,
|
||
photos: this.completePhotos,
|
||
meter_readings: this.completeMeters
|
||
.filter((m) => m.reading !== '' && m.reading != null)
|
||
.map((m) => ({ asset_pm_meter_id: m.asset_pm_meter_id, reading: Number(m.reading) }))
|
||
});
|
||
this.completeDialog = false;
|
||
}
|
||
}
|
||
};
|
||
</script>
|