Files
MixedAssets/src/components/AssetDrawer.vue
2026-06-10 02:09:25 -05:00

1080 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 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" />
<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 &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 || '' }}
<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>