Updated A LOT

This commit is contained in:
2026-06-05 04:15:24 -05:00
parent 8335f1af1b
commit 4072695432
92 changed files with 16139 additions and 570 deletions

View File

@@ -10,9 +10,13 @@
<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">
@@ -21,10 +25,22 @@
<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-text-field
v-model="localAsset.asset_id"
label="Asset ID"
:hint="localAsset.id ? '' : 'Leave blank to auto-assign from the categorys 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" />
@@ -39,13 +55,23 @@
<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-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-text-field v-model="localAsset.vendor" label="Vendor" />
<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" />
@@ -54,6 +80,20 @@
<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>
@@ -130,6 +170,38 @@
<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">
@@ -211,22 +283,335 @@
</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 &amp; 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 || '' }}<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>
</v-list-item-subtitle>
<template #append>
<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="$emit('save-asset', localAsset)">Save asset</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" />
<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>
<!-- 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 { conditionItems, conventionItems, newUsedItems, taskTypeItems } from '../constants';
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: '' };
@@ -236,19 +621,60 @@ 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 },
@@ -259,26 +685,118 @@ export default {
'upload-file', 'download-file', 'delete-file',
'add-warranty', 'delete-warranty',
'add-task', 'toggle-task', 'delete-task',
'add-note', 'delete-note'
'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', '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: [],
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 base = `${zone.name}: ${zone.allowance_percent}% §168 allowance applied to the FEDERAL book`
+ `${zone.pis_start || zone.pis_end ? ` for assets placed in service ${zone.pis_start || '…'} to ${zone.pis_end || '…'}` : ''}.`;
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 the allowance 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
},
@@ -287,12 +805,18 @@ export default {
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) {
@@ -301,6 +825,46 @@ export default {
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),
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;
},
@@ -315,6 +879,19 @@ export default {
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();
@@ -326,6 +903,73 @@ export default {
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;
},
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.completeDialog = true;
},
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
});
this.completeDialog = false;
}
}
};