Updated A LOT
This commit is contained in:
@@ -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 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" />
|
||||
@@ -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 & 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user