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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Asset classes</h2>
<p class="quiet text-body-2">
IRS asset classes (recovery periods) used to pre-populate a categorys class number. Seeded from the IRS tables and fully editable.
</p>
</div>
<v-spacer />
<v-select
v-model="sourceFilter"
:items="sourceFilterItems"
item-title="title"
item-value="value"
label="Source"
density="compact"
hide-details
style="max-width:190px"
/>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New asset class</v-btn>
</div>
<DataTable
:columns="columns"
:rows="filteredRows"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter by description or class number"
empty-text="No asset classes."
>
<template #cell-class_number="{ row }">
<span class="font-weight-bold">{{ row.class_number || '—' }}</span>
</template>
<template #cell-gds="{ row }">{{ row.gds ?? '—' }}</template>
<template #cell-ads="{ row }">{{ row.ads ?? '—' }}</template>
<template #cell-source="{ row }">
<v-chip size="x-small" variant="tonal">{{ sourceLabel(row.source) }}</v-chip>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="560">
<v-card>
<v-card-title>{{ form.id ? 'Edit asset class' : 'New asset class' }}</v-card-title>
<v-card-text>
<v-text-field v-model="form.description" label="Description *" autofocus />
<div class="form-grid">
<v-text-field v-model="form.class_number" label="Class number" placeholder="00.12" />
<v-select v-model="form.source" :items="sourceItems" item-title="title" item-value="value" label="Source" />
<v-text-field v-model.number="form.gds" type="number" label="GDS recovery (years)" />
<v-text-field v-model.number="form.ads" type="number" label="ADS recovery (years)" />
</div>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.description.trim()" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirmation -->
<v-dialog v-model="deleteDialog" max-width="480">
<v-card>
<v-card-title class="text-error">Delete asset class</v-card-title>
<v-card-text>
<p class="mb-2">Delete <strong>{{ deleteTarget?.description }}</strong>{{ deleteTarget?.class_number ? ` (${deleteTarget.class_number})` : '' }}?</p>
<v-alert
:type="deleteTarget && deleteTarget.category_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
>
<template v-if="deleteTarget && deleteTarget.category_count">
{{ deleteTarget.category_count }} categor{{ deleteTarget.category_count === 1 ? 'y' : 'ies' }} use this class number they keep
their value; it just wont appear in the lookup anymore.
</template>
<template v-else>This class isnt referenced by any category.</template>
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
const SOURCE_LABELS = { common: 'Common', industry: 'Manufacturing', business: 'Business', custom: 'Custom' };
function blankForm() {
return { id: null, description: '', class_number: '', gds: null, ads: null, source: 'custom' };
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
rows: [],
sourceFilter: 'all',
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: blankForm(),
saving: false,
error: '',
dialogError: '',
sourceItems: Object.entries(SOURCE_LABELS).map(([value, title]) => ({ title, value })),
columns: [
{ key: 'class_number', label: 'Class #' },
{ key: 'description', label: 'Description' },
{ key: 'gds', label: 'GDS', format: 'number' },
{ key: 'ads', label: 'ADS', format: 'number' },
{ key: 'source', label: 'Source', sortable: false },
{ key: 'category_count', label: 'Categories', format: 'number' }
]
};
},
computed: {
sourceFilterItems() {
return [{ title: 'All sources', value: 'all' }, ...this.sourceItems];
},
filteredRows() {
if (this.sourceFilter === 'all') return this.rows;
return this.rows.filter((r) => r.source === this.sourceFilter);
}
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
sourceLabel(source) {
return SOURCE_LABELS[source] || source;
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/asset-classes')).json();
this.rows = data.assetClasses;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankForm();
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { id: row.id, description: row.description, class_number: row.class_number || '', gds: row.gds, ads: row.ads, source: row.source };
this.dialogError = '';
this.dialog = true;
},
async save() {
if (!this.form.description.trim()) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/asset-classes/${this.form.id}` : '/api/asset-classes';
await this.api(url, { method, body: JSON.stringify(this.form) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/asset-classes/${target.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>

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;
}
}
};

View File

@@ -0,0 +1,224 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Asset ID templates</h2>
<p class="quiet text-body-2">
Naming patterns for auto-assigning asset tags. Use <strong>#</strong> where the number goes it is zero-padded to that
width and increments automatically (e.g. <code>DT-######</code> DT-000042). Assign a template to a category in
<strong>Asset categories</strong> below; new assets in that category get the next tag automatically.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New template</v-btn>
</div>
<DataTable
:columns="columns"
:rows="templates"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter templates"
empty-text="No ID templates yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-pattern="{ row }"><code>{{ row.pattern }}</code></template>
<template #cell-preview="{ row }"><code>{{ row.preview }}</code></template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="480">
<v-card>
<v-card-title>{{ form.id ? 'Edit ID template' : 'New ID template' }}</v-card-title>
<v-card-text>
<v-text-field v-model="form.name" label="Template name" autofocus />
<v-text-field
v-model="form.pattern"
label="Pattern"
placeholder="DT-######"
hint="Use # for the auto-incrementing number"
persistent-hint
/>
<v-text-field v-model.number="form.next_number" type="number" min="1" label="Next number" class="mt-2" />
<v-alert type="info" variant="tonal" density="comfortable" class="mt-3">
Next tag preview: <strong><code>{{ preview }}</code></strong>
</v-alert>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!canSave" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirmation -->
<v-dialog v-model="deleteDialog" max-width="500">
<v-card>
<v-card-title class="text-error">Delete ID template</v-card-title>
<v-card-text>
<p class="mb-3">Delete the template <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert
:type="deleteTarget && deleteTarget.category_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
class="mb-3"
>
<template v-if="deleteTarget && deleteTarget.category_count">
This template is assigned to <strong>{{ deleteTarget.category_count }}</strong>
categor{{ deleteTarget.category_count === 1 ? 'y' : 'ies' }}.
</template>
<template v-else>This template is not assigned to any category.</template>
</v-alert>
<ul class="delete-impact">
<li>Existing assets <strong>keep their current IDs</strong> nothing is renamed.</li>
<li>Affected categories are <strong>unassigned</strong>; new assets in them fall back to the default <code>MA-#####</code> sequence.</li>
</ul>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete template</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
function blankForm() {
return { id: null, name: '', pattern: '', next_number: 1 };
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
templates: [],
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: blankForm(),
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'name', label: 'Name' },
{ key: 'pattern', label: 'Pattern' },
{ key: 'preview', label: 'Next tag' },
{ key: 'next_number', label: 'Next #', format: 'number' },
{ key: 'category_count', label: 'Categories', format: 'number' }
]
};
},
computed: {
preview() {
const pattern = String(this.form.pattern || '');
const n = Math.max(1, Number(this.form.next_number) || 1);
if (!pattern.includes('#')) return '—';
return pattern.replace(/#+/, (hashes) => String(n).padStart(hashes.length, '0'));
},
canSave() {
return Boolean(this.form.name.trim()) && String(this.form.pattern).includes('#');
}
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/asset-id-templates')).json();
this.templates = data.templates;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankForm();
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { id: row.id, name: row.name, pattern: row.pattern, next_number: row.next_number };
this.dialogError = '';
this.dialog = true;
},
async save() {
if (!this.canSave) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/asset-id-templates/${this.form.id}` : '/api/asset-id-templates';
await this.api(url, {
method,
body: JSON.stringify({ name: this.form.name.trim(), pattern: String(this.form.pattern).trim(), next_number: this.form.next_number })
});
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/asset-id-templates/${target.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.delete-impact {
padding-left: 20px;
line-height: 1.6;
}
.delete-impact li {
margin-bottom: 4px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em;
padding: 1px 5px;
border-radius: 4px;
background: rgba(var(--v-theme-on-surface), 0.08);
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="barcode-label">
<svg ref="svg" class="barcode-svg" />
<div class="barcode-meta">
<div class="barcode-id">{{ value }}</div>
<div v-if="caption" class="barcode-caption">{{ caption }}</div>
</div>
</div>
</template>
<script>
import JsBarcode from 'jsbarcode';
export default {
props: {
value: { type: String, required: true },
caption: { type: String, default: '' },
format: { type: String, default: 'CODE128' }
},
mounted() {
this.render();
},
watch: {
value() {
this.render();
},
format() {
this.render();
}
},
methods: {
render() {
const svg = this.$refs.svg;
if (!svg || !this.value) return;
try {
JsBarcode(svg, this.value, {
format: this.format,
displayValue: false,
margin: 0,
height: 46,
width: 1.6
});
} catch {
// Value is not encodable in the chosen symbology; leave the SVG blank.
svg.innerHTML = '';
}
}
}
};
</script>

View File

@@ -0,0 +1,267 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Asset categories</h2>
<p class="quiet text-body-2">
Manage the categories offered in the asset forms Category dropdown. Renaming a category updates every asset that uses it.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New category</v-btn>
</div>
<DataTable
:columns="columns"
:rows="categories"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter categories"
empty-text="No categories yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
<template #cell-id_template_name="{ row }">
<span v-if="row.id_template_name">{{ row.id_template_name }} <span class="quiet">({{ row.id_template_pattern }})</span></span>
<span v-else class="quiet">Default (MA-#####)</span>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="460">
<v-card>
<v-card-title>{{ form.id ? 'Edit category' : 'New category' }}</v-card-title>
<v-card-text>
<v-text-field v-model="form.name" label="Category name" autofocus @keyup.enter="save" />
<v-text-field v-model="form.code" label="Short code (optional)" />
<v-autocomplete
v-model="classLookup"
:items="assetClassItems"
item-title="title"
item-value="value"
label="Look up IRS asset class (optional)"
placeholder="Search by name or class number…"
prepend-inner-icon="mdi-magnify"
clearable
@update:model-value="applyAssetClass"
/>
<v-text-field
v-model="form.asset_class_number"
label="Asset class number (optional)"
:hint="classHint || 'Shared by every asset in this category'"
persistent-hint
/>
<v-select
v-model="form.id_template_id"
:items="templateItems"
item-title="title"
item-value="value"
label="Asset ID template"
hint="New assets in this category get tags from this template"
persistent-hint
clearable
/>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirmation -->
<v-dialog v-model="deleteDialog" max-width="500">
<v-card>
<v-card-title class="text-error">Delete category</v-card-title>
<v-card-text>
<p class="mb-3">Delete the category <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert
:type="deleteTarget && deleteTarget.asset_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
class="mb-3"
>
<template v-if="deleteTarget && deleteTarget.asset_count">
<strong>{{ deleteTarget.asset_count }}</strong> asset{{ deleteTarget.asset_count === 1 ? '' : 's' }} currently
use this category.
</template>
<template v-else>No assets currently use this category.</template>
</v-alert>
<ul class="delete-impact">
<li>Those assets <strong>keep their current category value</strong> their data is not changed.</li>
<li>The category will <strong>no longer appear</strong> in the dropdown for new or edited assets.</li>
</ul>
<p class="quiet text-body-2 mt-3">Tip: to merge categories instead, <strong>rename</strong> this one to match another.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete category</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
categories: [],
idTemplates: [],
assetClasses: [],
classLookup: null,
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: { id: null, name: '', code: '', asset_class_number: '', id_template_id: null },
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'name', label: 'Category' },
{ key: 'code', label: 'Code' },
{ key: 'asset_class_number', label: 'Class #' },
{ key: 'id_template_name', label: 'ID template', sortable: false },
{ key: 'asset_count', label: 'Assets', format: 'number' }
]
};
},
computed: {
templateItems() {
return this.idTemplates.map((t) => ({ title: `${t.name} (${t.pattern})`, value: t.id }));
},
pickableClasses() {
return this.assetClasses.filter((c) => c.class_number);
},
assetClassItems() {
const tableLabels = { common: 'Common', industry: 'Manufacturing', business: 'Business', custom: 'Custom' };
return this.pickableClasses.map((c, index) => ({
title: `${c.class_number}${c.description} (GDS ${c.gds ?? '—'} / ADS ${c.ads ?? '—'}) · ${tableLabels[c.table] || 'Common'}`,
value: index
}));
},
classHint() {
const match = this.assetClasses.find((c) => c.class_number && c.class_number === String(this.form.asset_class_number || '').trim());
if (!match) return '';
return `IRS ${match.class_number} · ${match.description} · GDS ${match.gds ?? '—'} / ADS ${match.ads ?? '—'} yrs`;
}
},
mounted() {
this.load();
this.loadTemplates();
this.loadAssetClasses();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/asset-categories')).json();
this.categories = data.categories;
} catch (error) {
this.error = error.message;
}
},
async loadTemplates() {
try {
const data = await (await this.api('/api/asset-id-templates')).json();
this.idTemplates = data.templates;
} catch {
this.idTemplates = [];
}
},
async loadAssetClasses() {
try {
const data = await (await this.api('/api/asset-classes')).json();
this.assetClasses = data.assetClasses || [];
} catch {
this.assetClasses = [];
}
},
applyAssetClass(index) {
const entry = index != null ? this.pickableClasses[index] : null;
if (entry) this.form.asset_class_number = entry.class_number;
this.$nextTick(() => { this.classLookup = null; });
},
openNew() {
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
this.form = { id: null, name: '', code: '', asset_class_number: '', id_template_id: null };
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
this.form = { id: row.id, name: row.name, code: row.code || '', asset_class_number: row.asset_class_number || '', id_template_id: row.id_template_id || null };
this.dialogError = '';
this.dialog = true;
},
async save() {
const name = this.form.name.trim();
if (!name) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/asset-categories/${this.form.id}` : '/api/asset-categories';
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null, asset_class_number: this.form.asset_class_number || null, id_template_id: this.form.id_template_id || null }) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/asset-categories/${target.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.delete-impact {
padding-left: 20px;
line-height: 1.6;
}
.delete-impact li {
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="data-table">
<div v-if="searchable || $slots.toolbar" class="toolbar-row mb-2">
<v-text-field
v-if="searchable"
v-model="query"
:placeholder="searchPlaceholder"
density="compact"
hide-details
clearable
prepend-inner-icon="mdi-filter-variant"
style="max-width: 280px"
/>
<slot name="toolbar" />
</div>
<div class="table-scroll" :style="stickyHeader ? { maxHeight } : {}">
<table class="asset-table" :class="{ 'sticky-head': stickyHeader }">
<thead>
<tr>
<th v-if="hasLead" class="lead-col"><slot name="lead-header" /></th>
<th
v-for="col in columns"
:key="col.key"
:class="[alignClass(col), { sortable: col.sortable !== false }]"
@click="toggleSort(col)"
>
<span class="th-label">
{{ col.label }}
<v-icon v-if="sortKey === col.key" :icon="sortDir === 1 ? 'mdi-menu-up' : 'mdi-menu-down'" size="16" />
</span>
</th>
<th v-if="hasActions" class="actions-col" />
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in pagedRows" :key="keyFor(row, index)">
<td v-if="hasLead" class="lead-col"><slot name="lead" :row="row" /></td>
<td v-for="col in columns" :key="col.key" :class="alignClass(col)">
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
{{ formatCell(row[col.key], col.format) }}
</slot>
</td>
<td v-if="hasActions" class="text-right actions-col"><slot name="actions" :row="row" /></td>
</tr>
<tr v-if="!pagedRows.length">
<td :colspan="colspan" class="quiet">{{ emptyText }}</td>
</tr>
</tbody>
<tfoot v-if="totals">
<tr>
<td v-if="hasLead" />
<td v-for="(col, i) in columns" :key="col.key" :class="['font-weight-bold', alignClass(col)]">
{{ i === 0 ? 'Totals' : (col.key in totals ? formatCell(totals[col.key], col.format || 'currency') : '') }}
</td>
<td v-if="hasActions" />
</tr>
</tfoot>
</table>
</div>
<div class="data-table-footer">
<span class="quiet text-caption">{{ rangeLabel }}</span>
<v-spacer />
<span class="quiet text-caption mr-1">Rows per page</span>
<v-select
v-model="perPage"
:items="pageSizeItems"
item-title="title"
item-value="value"
density="compact"
variant="plain"
hide-details
style="max-width: 78px"
/>
<v-pagination
v-if="pageCount > 1"
v-model="page"
:length="pageCount"
:total-visible="5"
density="comfortable"
class="ml-2"
/>
</div>
</div>
</template>
<script>
import { currency, shortDate } from '../utils/format';
export default {
props: {
columns: { type: Array, required: true },
rows: { type: Array, required: true },
rowKey: { type: [String, Function], default: 'id' },
totals: { type: Object, default: null },
searchable: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: 'Filter rows' },
pageSize: { type: Number, default: 25 },
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
stickyHeader: { type: Boolean, default: true },
maxHeight: { type: String, default: '62vh' },
hasActions: { type: Boolean, default: false },
hasLead: { type: Boolean, default: false },
emptyText: { type: String, default: 'No data.' }
},
data() {
return {
sortKey: null,
sortDir: 1,
page: 1,
perPage: this.pageSize,
query: ''
};
},
computed: {
pageSizeItems() {
return [...this.pageSizeOptions.map((n) => ({ title: String(n), value: n })), { title: 'All', value: -1 }];
},
colspan() {
return this.columns.length + (this.hasLead ? 1 : 0) + (this.hasActions ? 1 : 0);
},
filteredRows() {
if (!this.searchable || !this.query) return this.rows;
const needle = String(this.query).toLowerCase();
return this.rows.filter((row) =>
this.columns.some((col) => String(row[col.key] ?? '').toLowerCase().includes(needle))
);
},
sortedRows() {
if (!this.sortKey) return this.filteredRows;
const col = this.columns.find((c) => c.key === this.sortKey);
const numeric = col && (col.format === 'currency' || col.format === 'number');
const dated = col && col.format === 'date';
const dir = this.sortDir;
return [...this.filteredRows].sort((a, b) => {
let av = a[this.sortKey];
let bv = b[this.sortKey];
if (numeric) return (Number(av || 0) - Number(bv || 0)) * dir;
if (dated) return ((new Date(av || 0)) - (new Date(bv || 0))) * dir;
av = String(av ?? '');
bv = String(bv ?? '');
return av.localeCompare(bv, undefined, { numeric: true }) * dir;
});
},
pageCount() {
if (this.perPage === -1) return 1;
return Math.max(1, Math.ceil(this.sortedRows.length / this.perPage));
},
pagedRows() {
if (this.perPage === -1) return this.sortedRows;
const start = (this.page - 1) * this.perPage;
return this.sortedRows.slice(start, start + this.perPage);
},
rangeLabel() {
const total = this.sortedRows.length;
if (!total) return '0 of 0';
if (this.perPage === -1) return `1${total} of ${total}`;
const start = (this.page - 1) * this.perPage + 1;
const end = Math.min(start + this.perPage - 1, total);
return `${start}${end} of ${total}`;
}
},
watch: {
rows() {
if (this.page > this.pageCount) this.page = this.pageCount;
},
perPage() {
this.page = 1;
},
query() {
this.page = 1;
}
},
methods: {
toggleSort(col) {
if (col.sortable === false) return;
if (this.sortKey === col.key) {
if (this.sortDir === 1) this.sortDir = -1;
else this.sortKey = null;
} else {
this.sortKey = col.key;
this.sortDir = 1;
}
},
alignClass(col) {
return col.align === 'end' || col.format === 'currency' || col.format === 'number' ? 'text-right' : '';
},
keyFor(row, index) {
if (typeof this.rowKey === 'function') {
const key = this.rowKey(row);
return key == null ? index : key;
}
return row[this.rowKey] ?? index;
},
formatCell(value, format) {
if (value === null || value === undefined || value === '') return '';
if (format === 'currency') return currency(value);
if (format === 'number') return Number(value).toLocaleString();
if (format === 'date') return shortDate(value);
return value;
}
}
};
</script>

View File

@@ -0,0 +1,186 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Asset departments</h2>
<p class="quiet text-body-2">
Manage the departments offered in the asset forms Department dropdown. Renaming a department updates every asset that uses it.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New department</v-btn>
</div>
<DataTable
:columns="columns"
:rows="departments"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter departments"
empty-text="No departments yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="460">
<v-card>
<v-card-title>{{ form.id ? 'Edit department' : 'New department' }}</v-card-title>
<v-card-text>
<v-text-field v-model="form.name" label="Department name" autofocus @keyup.enter="save" />
<v-text-field v-model="form.code" label="Short code (optional)" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirmation -->
<v-dialog v-model="deleteDialog" max-width="500">
<v-card>
<v-card-title class="text-error">Delete department</v-card-title>
<v-card-text>
<p class="mb-3">Delete the department <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert
:type="deleteTarget && deleteTarget.asset_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
class="mb-3"
>
<template v-if="deleteTarget && deleteTarget.asset_count">
<strong>{{ deleteTarget.asset_count }}</strong> asset{{ deleteTarget.asset_count === 1 ? '' : 's' }} currently
use this department.
</template>
<template v-else>No assets currently use this department.</template>
</v-alert>
<ul class="delete-impact">
<li>Those assets <strong>keep their current department value</strong> their data is not changed.</li>
<li>The department will <strong>no longer appear</strong> in the dropdown for new or edited assets.</li>
</ul>
<p class="quiet text-body-2 mt-3">Tip: to merge departments instead, <strong>rename</strong> this one to match another.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete department</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
departments: [],
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: { id: null, name: '', code: '' },
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'name', label: 'Department' },
{ key: 'code', label: 'Code' },
{ key: 'asset_count', label: 'Assets', format: 'number' }
]
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/asset-departments')).json();
this.departments = data.departments;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = { id: null, name: '', code: '' };
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { id: row.id, name: row.name, code: row.code || '' };
this.dialogError = '';
this.dialog = true;
},
async save() {
const name = this.form.name.trim();
if (!name) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/asset-departments/${this.form.id}` : '/api/asset-departments';
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null }) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/asset-departments/${target.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.delete-impact {
padding-left: 20px;
line-height: 1.6;
}
.delete-impact li {
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Depreciation zones</h2>
<p class="quiet text-body-2">
Special-allowance zones (e.g. New York Liberty Zone). Assign one to an asset and the engine applies the zones §168
allowance to the federal book for assets placed in service within the date window.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New zone</v-btn>
</div>
<DataTable
:columns="columns"
:rows="zones"
row-key="code"
:page-size="10"
has-actions
searchable
search-placeholder="Filter zones"
empty-text="No depreciation zones."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
<v-chip v-if="!row.enabled" size="x-small" variant="tonal" class="ml-2">Disabled</v-chip>
</template>
<template #cell-allowance_percent="{ row }">{{ row.allowance_percent }}%</template>
<template #cell-window="{ row }">{{ row.pis_start || '—' }} {{ row.pis_end || '—' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-dialog v-model="dialog" max-width="640" scrollable>
<v-card>
<v-card-title>{{ form.exists ? `Edit zone · ${form.name}` : 'New depreciation zone' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.name" label="Zone name *" />
<v-text-field v-if="!form.exists" v-model="form.code" label="Code (optional)" placeholder="auto from name" />
<v-text-field v-model.number="form.allowance_percent" type="number" label="§168 special allowance %" suffix="%" />
<v-switch v-model="form.enabled" label="Enabled" color="primary" density="compact" hide-details />
<v-text-field v-model="form.pis_start" type="date" label="Placed-in-service start" />
<v-text-field v-model="form.pis_end" type="date" label="Placed-in-service end" />
<v-text-field v-model="form.real_property_end" type="date" label="Real-property PIS end (optional)" />
<v-text-field v-model.number="form.max_recovery_years" type="number" label="Max recovery years (optional)" />
<v-text-field v-model.number="form.leasehold_life_years" type="number" label="Leasehold improvement life (yrs, optional)" />
</div>
<v-textarea v-model="form.notes" class="mt-2" label="Notes" rows="3" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save zone</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog" max-width="460">
<v-card>
<v-card-title class="text-error">Delete zone</v-card-title>
<v-card-text>
<p>Delete <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert v-if="deleteTarget && deleteTarget.asset_count" type="warning" variant="tonal" density="comfortable" class="mt-2">
{{ deleteTarget.asset_count }} asset(s) reference this zone they keep their value, but the special allowance stops applying.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
function blankForm() {
return {
exists: false, code: '', name: '', allowance_percent: 0, enabled: true,
pis_start: '', pis_end: '', real_property_end: '', max_recovery_years: null, leasehold_life_years: null, notes: ''
};
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
zones: [],
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: blankForm(),
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'name', label: 'Zone' },
{ key: 'allowance_percent', label: 'Allowance' },
{ key: 'window', label: 'Placed-in-service window', sortable: false },
{ key: 'asset_count', label: 'Assets', format: 'number' }
]
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/depreciation-zones')).json();
this.zones = data.zones;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankForm();
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = {
exists: true, code: row.code, name: row.name, allowance_percent: row.allowance_percent, enabled: row.enabled,
pis_start: row.pis_start || '', pis_end: row.pis_end || '', real_property_end: row.real_property_end || '',
max_recovery_years: row.max_recovery_years, leasehold_life_years: row.leasehold_life_years, notes: row.notes || ''
};
this.dialogError = '';
this.dialog = true;
},
async save() {
if (!this.form.name.trim()) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.exists ? 'PUT' : 'POST';
const url = this.form.exists ? `/api/depreciation-zones/${this.form.code}` : '/api/depreciation-zones';
await this.api(url, { method, body: JSON.stringify(this.form) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/depreciation-zones/${target.code}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>

View File

@@ -0,0 +1,80 @@
<template>
<v-dialog :model-value="modelValue" max-width="940" scrollable @update:model-value="$emit('update:modelValue', $event)">
<v-card>
<v-card-title class="d-flex align-center">
<span>Print asset labels</span>
<v-spacer />
<v-select
v-model="format"
:items="formatItems"
density="compact"
hide-details
label="Symbology"
style="max-width: 170px"
class="mr-3"
/>
<v-btn color="primary" prepend-icon="mdi-printer" :disabled="!assets.length" @click="print">Print</v-btn>
</v-card-title>
<v-card-text>
<p v-if="!assets.length" class="quiet text-body-2">No assets to label.</p>
<div ref="sheet" class="label-grid">
<div v-for="asset in assets" :key="asset.id || asset.asset_id" class="label-cell">
<BarcodeLabel :value="asset.barcode_value || asset.asset_id" :caption="asset.description" :format="format" />
</div>
</div>
</v-card-text>
<v-card-actions>
<span class="quiet text-caption px-2">{{ assets.length }} label(s)</span>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import BarcodeLabel from './BarcodeLabel.vue';
const PRINT_CSS = `
@page { margin: 12mm; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; }
.label-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10mm 8mm; }
.label-cell { border: 1px solid #ddd; border-radius: 6px; padding: 8px; text-align: center; page-break-inside: avoid; }
.barcode-svg { width: 100%; height: 46px; }
.barcode-id { font-weight: 700; font-size: 12px; margin-top: 4px; letter-spacing: 0.04em; }
.barcode-caption { font-size: 10px; color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
`;
export default {
components: { BarcodeLabel },
props: {
assets: { type: Array, default: () => [] },
modelValue: { type: Boolean, default: false }
},
emits: ['update:modelValue'],
data() {
return {
format: 'CODE128',
formatItems: ['CODE128', 'CODE39']
};
},
methods: {
print() {
const sheet = this.$refs.sheet;
if (!sheet) return;
const win = window.open('', '_blank', 'width=960,height=720');
if (!win) return;
win.document.write(
`<html><head><title>Asset labels</title><style>${PRINT_CSS}</style></head>` +
`<body><div class="label-grid">${sheet.innerHTML}</div></body></html>`
);
win.document.close();
win.focus();
setTimeout(() => {
win.print();
win.close();
}, 350);
}
}
};
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div ref="container" class="monaco-host" />
</template>
<script>
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
// Configure Monaco web workers once (Vite bundles each ?worker as its own chunk).
if (!window.__mixedassetsMonacoEnv) {
self.MonacoEnvironment = {
getWorker(_workerId, label) {
return label === 'json' ? new jsonWorker() : new editorWorker();
}
};
window.__mixedassetsMonacoEnv = true;
}
export default {
props: {
modelValue: { type: String, default: '' },
readOnly: { type: Boolean, default: false },
theme: { type: String, default: 'vs-dark' }
},
emits: ['update:modelValue'],
mounted() {
this.editor = monaco.editor.create(this.$refs.container, {
value: this.modelValue,
language: 'json',
theme: this.theme,
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
tabSize: 2,
scrollBeyondLastLine: false,
readOnly: this.readOnly,
formatOnPaste: true,
fixedOverflowWidgets: true
});
this.editor.onDidChangeModelContent(() => {
const value = this.editor.getValue();
if (value !== this.modelValue) this.$emit('update:modelValue', value);
});
},
beforeUnmount() {
this.editor?.dispose();
},
watch: {
modelValue(value) {
if (this.editor && value !== this.editor.getValue()) this.editor.setValue(value || '');
},
readOnly(value) {
this.editor?.updateOptions({ readOnly: value });
},
theme(value) {
monaco.editor.setTheme(value);
}
},
methods: {
format() {
this.editor?.getAction('editor.action.formatDocument')?.run();
}
}
};
</script>
<style scoped>
.monaco-host {
width: 100%;
height: 540px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 8px;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<v-card class="span-12 panel-pad">
<h2 class="section-title mb-1">Email &amp; alerts (SMTP)</h2>
<p class="quiet text-body-2 mb-3">Mail server used for reminder and alert emails. Leave the password blank to keep the stored value.</p>
<div class="form-grid">
<v-switch v-model="form.alerts_enabled" label="Enable email alerts" color="primary" density="compact" hide-details />
<v-text-field v-model.number="form.alerts_lead_days" type="number" label="Lead days (warn ahead of due date)" />
<v-text-field v-model="form.alerts_recipients" class="full" label="Alert recipients (comma-separated)" />
<v-text-field v-model="form.smtp_host" label="SMTP host" />
<v-text-field v-model.number="form.smtp_port" type="number" label="SMTP port" />
<v-switch v-model="form.smtp_secure" label="Use TLS (secure)" color="primary" density="compact" hide-details />
<v-text-field v-model="form.smtp_user" label="SMTP username" />
<v-text-field
v-model="form.smtp_password"
type="password"
:label="settings.smtp_password_set ? 'SMTP password (unchanged)' : 'SMTP password'"
autocomplete="new-password"
/>
<v-text-field v-model="form.smtp_from" class="full" label="From address" />
</div>
<div class="toolbar-row mt-3">
<v-text-field v-model="testTo" label="Send test to (optional)" density="compact" hide-details style="max-width: 280px" />
<v-btn variant="tonal" prepend-icon="mdi-email-fast-outline" :loading="testing" @click="sendTest">Send test</v-btn>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save settings</v-btn>
</div>
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
</v-card>
</template>
<script>
import { apiRequest } from '../services/api';
export default {
props: {
token: { type: String, required: true }
},
data() {
return {
settings: {},
form: {
alerts_enabled: false,
alerts_lead_days: 30,
alerts_recipients: '',
smtp_host: '',
smtp_port: 587,
smtp_secure: false,
smtp_user: '',
smtp_password: '',
smtp_from: ''
},
testTo: '',
saving: false,
testing: false,
status: '',
statusType: 'success'
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/notifications/settings')).json();
this.settings = data.settings;
this.form = {
alerts_enabled: data.settings.alerts_enabled,
alerts_lead_days: data.settings.alerts_lead_days,
alerts_recipients: data.settings.alerts_recipients,
smtp_host: data.settings.smtp_host,
smtp_port: data.settings.smtp_port,
smtp_secure: data.settings.smtp_secure,
smtp_user: data.settings.smtp_user,
smtp_password: '',
smtp_from: data.settings.smtp_from
};
},
async save() {
this.saving = true;
this.status = '';
try {
const payload = { ...this.form };
if (!payload.smtp_password) delete payload.smtp_password;
const data = await (await this.api('/api/notifications/settings', { method: 'PUT', body: JSON.stringify(payload) })).json();
this.settings = data.settings;
this.form.smtp_password = '';
this.statusType = 'success';
this.status = 'Settings saved.';
} catch (error) {
this.statusType = 'error';
this.status = error.message;
} finally {
this.saving = false;
}
},
async sendTest() {
this.testing = true;
this.status = '';
try {
await this.api('/api/notifications/test', { method: 'POST', body: JSON.stringify({ to: this.testTo || undefined }) });
this.statusType = 'success';
this.status = `Test email sent${this.testTo ? ` to ${this.testTo}` : ''}.`;
} catch (error) {
this.statusType = 'error';
this.status = `Test failed: ${error.message}`;
} finally {
this.testing = false;
}
}
}
};
</script>

View File

@@ -0,0 +1,187 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Parts categories</h2>
<p class="quiet text-body-2">
Manage the categories offered in the parts inventory form. Separate from asset and PM categories.
Renaming a category updates every part that uses it.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New category</v-btn>
</div>
<DataTable
:columns="columns"
:rows="categories"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter categories"
empty-text="No parts categories yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="460">
<v-card>
<v-card-title>{{ form.id ? 'Edit parts category' : 'New parts category' }}</v-card-title>
<v-card-text>
<v-text-field v-model="form.name" label="Category name" autofocus @keyup.enter="save" />
<v-text-field v-model="form.code" label="Short code (optional)" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirmation -->
<v-dialog v-model="deleteDialog" max-width="500">
<v-card>
<v-card-title class="text-error">Delete parts category</v-card-title>
<v-card-text>
<p class="mb-3">Delete the parts category <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert
:type="deleteTarget && deleteTarget.part_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
class="mb-3"
>
<template v-if="deleteTarget && deleteTarget.part_count">
<strong>{{ deleteTarget.part_count }}</strong> part{{ deleteTarget.part_count === 1 ? '' : 's' }} currently
use this category.
</template>
<template v-else>No parts currently use this category.</template>
</v-alert>
<ul class="delete-impact">
<li>Those parts <strong>keep their current category value</strong> their data is not changed.</li>
<li>The category will <strong>no longer appear</strong> in the dropdown for new or edited parts.</li>
</ul>
<p class="quiet text-body-2 mt-3">Tip: to merge categories instead, <strong>rename</strong> this one to match another.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete category</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
categories: [],
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: { id: null, name: '', code: '' },
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'name', label: 'Category' },
{ key: 'code', label: 'Code' },
{ key: 'part_count', label: 'Parts', format: 'number' }
]
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/part-categories')).json();
this.categories = data.categories;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = { id: null, name: '', code: '' };
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { id: row.id, name: row.name, code: row.code || '' };
this.dialogError = '';
this.dialog = true;
},
async save() {
const name = this.form.name.trim();
if (!name) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/part-categories/${this.form.id}` : '/api/part-categories';
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null }) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/part-categories/${target.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.delete-impact {
padding-left: 20px;
line-height: 1.6;
}
.delete-impact li {
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">PM categories</h2>
<p class="quiet text-body-2">
Manage the categories offered in the preventative-maintenance plan form. Separate from asset categories.
Renaming a category updates every PM plan that uses it.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New category</v-btn>
</div>
<DataTable
:columns="columns"
:rows="categories"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter categories"
empty-text="No PM categories yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="460">
<v-card>
<v-card-title>{{ form.id ? 'Edit PM category' : 'New PM category' }}</v-card-title>
<v-card-text>
<v-text-field v-model="form.name" label="Category name" autofocus @keyup.enter="save" />
<v-text-field v-model="form.code" label="Short code (optional)" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirmation -->
<v-dialog v-model="deleteDialog" max-width="500">
<v-card>
<v-card-title class="text-error">Delete PM category</v-card-title>
<v-card-text>
<p class="mb-3">Delete the PM category <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert
:type="deleteTarget && deleteTarget.plan_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
class="mb-3"
>
<template v-if="deleteTarget && deleteTarget.plan_count">
<strong>{{ deleteTarget.plan_count }}</strong> PM plan{{ deleteTarget.plan_count === 1 ? '' : 's' }} currently
use this category.
</template>
<template v-else>No PM plans currently use this category.</template>
</v-alert>
<ul class="delete-impact">
<li>Those plans <strong>keep their current category value</strong> their data is not changed.</li>
<li>The category will <strong>no longer appear</strong> in the dropdown for new or edited PM plans.</li>
</ul>
<p class="quiet text-body-2 mt-3">Tip: to merge categories instead, <strong>rename</strong> this one to match another.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete category</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
categories: [],
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: { id: null, name: '', code: '' },
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'name', label: 'Category' },
{ key: 'code', label: 'Code' },
{ key: 'plan_count', label: 'Plans', format: 'number' }
]
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/pm-categories')).json();
this.categories = data.categories;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = { id: null, name: '', code: '' };
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { id: row.id, name: row.name, code: row.code || '' };
this.dialogError = '';
this.dialog = true;
},
async save() {
const name = this.form.name.trim();
if (!name) return;
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/pm-categories/${this.form.id}` : '/api/pm-categories';
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null }) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
async performDelete() {
const target = this.deleteTarget;
this.deleteDialog = false;
this.deleteTarget = null;
if (!target) return;
try {
await this.api(`/api/pm-categories/${target.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.delete-impact {
padding-left: 20px;
line-height: 1.6;
}
.delete-impact li {
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<v-dialog :model-value="modelValue" max-width="660" scrollable @update:model-value="$emit('update:modelValue', $event)">
<v-card v-if="completion">
<v-card-title>PM completion {{ completion.asset_code }}</v-card-title>
<v-card-text>
<div class="quiet text-body-2 mb-3">
{{ completion.plan_name || 'Maintenance' }} · completed {{ completion.completed_at }}
by {{ completion.completed_by_name || 'Unknown' }} · cost {{ currency(completion.cost) }}
</div>
<div class="rating-row mb-3">
<div><div class="quiet text-caption">Safety / is it safe?</div><v-rating :model-value="completion.ratings.safety || 0" length="5" readonly density="compact" color="amber" size="small" /></div>
<div><div class="quiet text-caption">Physical condition</div><v-rating :model-value="completion.ratings.physical || 0" length="5" readonly density="compact" color="amber" size="small" /></div>
<div><div class="quiet text-caption">Operating condition</div><v-rating :model-value="completion.ratings.operating || 0" length="5" readonly density="compact" color="amber" size="small" /></div>
</div>
<template v-if="completion.steps.length">
<h3 class="section-title mb-1">Steps</h3>
<div v-for="(s, i) in completion.steps" :key="`s${i}`" class="text-body-2">
<v-icon :icon="s.done ? 'mdi-check-circle' : 'mdi-circle-outline'" :color="s.done ? 'success' : ''" size="16" /> {{ s.title }}
</div>
</template>
<template v-if="completion.notes.length">
<h3 class="section-title mt-3 mb-1">Completion notes</h3>
<ul class="pl-4">
<li v-for="(n, i) in completion.notes" :key="`n${i}`" class="text-body-2">{{ n }}</li>
</ul>
</template>
<template v-if="completion.components.length">
<h3 class="section-title mt-3 mb-1">Parts</h3>
<div v-for="(c, i) in completion.components" :key="`c${i}`" class="d-flex text-body-2">
<span>{{ c.part_number ? c.part_number + ' · ' : '' }}{{ c.description }}</span>
<v-spacer /><span>{{ currency(c.cost) }}</span>
</div>
</template>
<template v-if="completion.photos.length">
<h3 class="section-title mt-3 mb-1">Photos</h3>
<div class="pm-photo-grid">
<a v-for="p in completion.photos" :key="p.id" :href="p.data" target="_blank" rel="noopener">
<img :src="p.data" class="pm-photo" :alt="p.name || 'photo'" />
</a>
</div>
</template>
<template v-if="completion.signature">
<h3 class="section-title mt-3 mb-1">Signature</h3>
<img :src="completion.signature" class="pm-signature" alt="signature" />
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
</v-card-actions>
</v-card>
<v-card v-else>
<v-card-text class="quiet">Loading</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
import { apiRequest } from '../services/api';
import { currency } from '../utils/format';
export default {
props: {
token: { type: String, required: true },
modelValue: { type: Boolean, default: false },
completionId: { type: [Number, String], default: null }
},
emits: ['update:modelValue'],
data() {
return { completion: null };
},
watch: {
completionId() {
if (this.modelValue) this.load();
},
modelValue(open) {
if (open) this.load();
}
},
methods: {
currency,
async load() {
this.completion = null;
if (!this.completionId) return;
const data = await (await apiRequest(`/api/pm-completions/${this.completionId}`, this.token)).json();
this.completion = data.completion;
}
}
};
</script>
<style scoped>
.rating-row {
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.pm-photo-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pm-photo {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
}
.pm-signature {
max-width: 100%;
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
border-radius: 8px;
background: #fff;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<v-card class="span-12 panel-pad">
<h2 class="section-title mb-1">Preventative maintenance defaults</h2>
<p class="quiet text-body-2 mb-3">Default cadence applied when attaching a PM plan to an asset, and how many days ahead PM-due alerts are raised.</p>
<div class="form-grid">
<v-text-field v-model.number="form.pm_default_frequency_value" type="number" label="Default frequency (every)" />
<v-select v-model="form.pm_default_frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Default frequency unit" />
<v-text-field v-model.number="form.pm_lead_days" type="number" label="Alert lead days" />
</div>
<div class="toolbar-row mt-3">
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save PM defaults</v-btn>
</div>
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
</v-card>
</template>
<script>
import { apiRequest } from '../services/api';
import { pmFrequencyUnits } from '../constants';
export default {
props: {
token: { type: String, required: true }
},
data() {
return {
pmFrequencyUnits,
form: { pm_default_frequency_value: 3, pm_default_frequency_unit: 'months', pm_lead_days: 7 },
saving: false,
status: '',
statusType: 'success'
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/pm-settings')).json();
this.form = { ...this.form, ...data.settings };
},
async save() {
this.saving = true;
this.status = '';
try {
const data = await (await this.api('/api/pm-settings', { method: 'PUT', body: JSON.stringify(this.form) })).json();
this.form = { ...this.form, ...data.settings };
this.statusType = 'success';
this.status = 'PM defaults saved.';
} catch (error) {
this.statusType = 'error';
this.status = error.message;
} finally {
this.saving = false;
}
}
}
};
</script>

View File

@@ -0,0 +1,213 @@
<template>
<v-card class="span-12 panel-pad">
<h2 class="section-title mb-1">ServiceNow integration</h2>
<p class="quiet text-body-2 mb-3">
Submit alerts as ServiceNow incidents (Table API) and pull asset data from the CMDB. Uses basic auth over HTTPS.
Leave the password blank to keep the stored value.
</p>
<h3 class="section-title text-body-1 mb-2">Connection</h3>
<div class="form-grid">
<v-text-field v-model="form.servicenow_instance_url" class="full" label="Instance URL" placeholder="https://dev12345.service-now.com" />
<v-text-field v-model="form.servicenow_username" label="Username (integration user)" autocomplete="off" />
<v-text-field
v-model="form.servicenow_password"
type="password"
:label="settings.servicenow_password_set ? 'Password (unchanged)' : 'Password'"
autocomplete="new-password"
/>
<div class="d-flex align-center">
<v-btn v-if="settings.servicenow_password_set" variant="text" size="small" color="error" prepend-icon="mdi-key-remove" @click="clearPassword">Clear password</v-btn>
</div>
</div>
<v-divider class="my-4" />
<h3 class="section-title text-body-1 mb-2">Incident creation (push)</h3>
<div class="form-grid">
<v-switch v-model="form.servicenow_ticket_enabled" label="Allow submitting alerts as incidents" color="primary" density="compact" hide-details />
<v-switch v-model="form.servicenow_auto_ticket" label="Auto-create incidents for new critical alerts" color="primary" density="compact" hide-details />
<v-text-field v-model="form.servicenow_incident_table" label="Incident table" placeholder="incident" />
<v-text-field v-model="form.servicenow_assignment_group" label="Assignment group (sys_id or name, optional)" />
<v-text-field v-model="form.servicenow_caller_id" class="full" label="Caller / requested-for (sys_id, optional)" />
</div>
<v-divider class="my-4" />
<h3 class="section-title text-body-1 mb-2">CMDB asset sync (pull)</h3>
<div class="form-grid">
<v-text-field v-model="form.servicenow_cmdb_table" label="CMDB CI table" placeholder="cmdb_ci_hardware" />
<v-text-field v-model.number="form.servicenow_cmdb_limit" type="number" label="Max records per sync" />
<v-text-field v-model="form.servicenow_cmdb_query" class="full" label="Filter (sysparm_query, optional)" placeholder="install_status=1^operational_status=1" />
</div>
<v-textarea
v-model="cmdbMapText"
class="mt-2"
label="Field mapping (asset field → CMDB column, JSON)"
:rows="6"
hint="Maps MixedAssets asset fields to ServiceNow CI columns. Leave default to use the built-in hardware mapping."
persistent-hint
:error-messages="mapError"
/>
<div class="toolbar-row mt-3">
<v-btn variant="tonal" prepend-icon="mdi-database-sync" :loading="syncing" :disabled="!settings.configured" @click="syncCmdb">Sync CMDB now</v-btn>
<div class="quiet text-body-2 ml-2">Last sync: {{ settings.servicenow_last_sync_at ? shortDate(settings.servicenow_last_sync_at) : 'Never' }}</div>
</div>
<v-alert v-if="settings.servicenow_last_sync_status" class="mt-2" density="compact" type="info">{{ settings.servicenow_last_sync_status }}</v-alert>
<v-divider class="my-4" />
<div class="toolbar-row">
<v-btn variant="tonal" prepend-icon="mdi-connection" :loading="testing" :disabled="!form.servicenow_instance_url" @click="test">Test connection</v-btn>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save ServiceNow settings</v-btn>
</div>
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
</v-card>
</template>
<script>
import { apiRequest } from '../services/api';
import { shortDate } from '../utils/format';
function blankForm() {
return {
servicenow_instance_url: '',
servicenow_username: '',
servicenow_password: '',
servicenow_incident_table: 'incident',
servicenow_assignment_group: '',
servicenow_caller_id: '',
servicenow_ticket_enabled: false,
servicenow_auto_ticket: false,
servicenow_cmdb_table: 'cmdb_ci_hardware',
servicenow_cmdb_query: '',
servicenow_cmdb_limit: 200
};
}
export default {
props: {
token: { type: String, required: true }
},
data() {
return {
settings: {},
form: blankForm(),
cmdbMapText: '',
mapError: '',
saving: false,
testing: false,
syncing: false,
status: '',
statusType: 'success'
};
},
mounted() {
this.load();
},
methods: {
shortDate,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
apply(settings) {
this.settings = settings;
this.form = {
servicenow_instance_url: settings.servicenow_instance_url || '',
servicenow_username: settings.servicenow_username || '',
servicenow_password: '',
servicenow_incident_table: settings.servicenow_incident_table || 'incident',
servicenow_assignment_group: settings.servicenow_assignment_group || '',
servicenow_caller_id: settings.servicenow_caller_id || '',
servicenow_ticket_enabled: settings.servicenow_ticket_enabled || false,
servicenow_auto_ticket: settings.servicenow_auto_ticket || false,
servicenow_cmdb_table: settings.servicenow_cmdb_table || 'cmdb_ci_hardware',
servicenow_cmdb_query: settings.servicenow_cmdb_query || '',
servicenow_cmdb_limit: settings.servicenow_cmdb_limit || 200
};
this.cmdbMapText = JSON.stringify(settings.servicenow_cmdb_map || {}, null, 2);
},
async load() {
const data = await (await this.api('/api/servicenow/settings')).json();
this.apply(data.settings);
},
buildPayload() {
this.mapError = '';
const payload = { ...this.form };
if (!payload.servicenow_password) delete payload.servicenow_password;
const text = this.cmdbMapText.trim();
if (text) {
try {
payload.servicenow_cmdb_map = JSON.parse(text);
} catch {
this.mapError = 'Field mapping must be valid JSON.';
return null;
}
} else {
payload.servicenow_cmdb_map = '';
}
return payload;
},
async save() {
const payload = this.buildPayload();
if (!payload) return;
this.saving = true;
this.status = '';
try {
const data = await (await this.api('/api/servicenow/settings', { method: 'PUT', body: JSON.stringify(payload) })).json();
this.apply(data.settings);
this.statusType = 'success';
this.status = 'ServiceNow settings saved.';
} catch (error) {
this.statusType = 'error';
this.status = error.message;
} finally {
this.saving = false;
}
},
async clearPassword() {
this.status = '';
try {
const data = await (await this.api('/api/servicenow/settings', { method: 'PUT', body: JSON.stringify({ servicenow_password_clear: true }) })).json();
this.apply(data.settings);
this.statusType = 'success';
this.status = 'Password cleared.';
} catch (error) {
this.statusType = 'error';
this.status = error.message;
}
},
async test() {
this.testing = true;
this.status = '';
try {
await this.save();
const result = await (await this.api('/api/servicenow/test', { method: 'POST', body: JSON.stringify({}) })).json();
this.statusType = 'success';
this.status = `Connected to ${result.instance}.`;
} catch (error) {
this.statusType = 'error';
this.status = `Connection failed: ${error.message}`;
} finally {
this.testing = false;
}
},
async syncCmdb() {
const payload = this.buildPayload();
if (!payload) return;
this.syncing = true;
this.status = '';
try {
await this.save();
const result = await (await this.api('/api/servicenow/cmdb-sync', { method: 'POST', body: JSON.stringify({}) })).json();
this.statusType = 'success';
this.status = result.status || `Synced ${result.total} CI(s).`;
await this.load();
} catch (error) {
this.statusType = 'error';
this.status = `Sync failed: ${error.message}`;
} finally {
this.syncing = false;
}
}
}
};
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div>
<canvas
ref="canvas"
class="sig-canvas"
@pointerdown="start"
@pointermove="draw"
@pointerup="end"
@pointerleave="end"
/>
<div class="d-flex align-center mt-1">
<span class="quiet text-caption">Sign in the box above</span>
<v-spacer />
<v-btn size="x-small" variant="text" @click="clear">Clear</v-btn>
</div>
</div>
</template>
<script>
export default {
props: {
modelValue: { type: String, default: '' }
},
emits: ['update:modelValue'],
data() {
return { drawing: false, ctx: null, hasInk: false };
},
mounted() {
const canvas = this.$refs.canvas;
canvas.width = canvas.offsetWidth || 460;
canvas.height = 150;
this.ctx = canvas.getContext('2d');
this.ctx.lineWidth = 2;
this.ctx.lineCap = 'round';
this.ctx.strokeStyle = '#111';
},
methods: {
pos(event) {
const rect = this.$refs.canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
},
start(event) {
this.drawing = true;
const point = this.pos(event);
this.ctx.beginPath();
this.ctx.moveTo(point.x, point.y);
try { this.$refs.canvas.setPointerCapture(event.pointerId); } catch { /* not supported */ }
},
draw(event) {
if (!this.drawing) return;
const point = this.pos(event);
this.ctx.lineTo(point.x, point.y);
this.ctx.stroke();
this.hasInk = true;
},
end() {
if (!this.drawing) return;
this.drawing = false;
if (this.hasInk) this.$emit('update:modelValue', this.$refs.canvas.toDataURL('image/png'));
},
clear() {
this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height);
this.hasInk = false;
this.$emit('update:modelValue', '');
}
}
};
</script>
<style scoped>
.sig-canvas {
width: 100%;
height: 150px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.3);
border-radius: 8px;
background: #fff;
touch-action: none;
cursor: crosshair;
}
</style>

View File

@@ -1,14 +1,16 @@
<template>
<v-app-bar class="top-nav" density="compact" flat height="64">
<v-btn icon="mdi-menu" variant="text" class="mr-1" title="Toggle menu" aria-label="Toggle navigation menu" @click="$emit('toggle-nav')" />
<div class="top-nav-title">
<div class="text-subtitle-2 quiet">{{ subtitle }}</div>
<div class="text-h6 font-weight-bold">{{ title }}</div>
</div>
<v-spacer />
<slot name="actions" />
<v-btn icon="mdi-help-circle-outline" variant="text" class="mr-1" title="User guide" aria-label="Open user guide" @click="$emit('open-help')" />
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<v-btn v-bind="props" class="profile-trigger" variant="text">
<v-btn v-bind="props" class="profile-trigger" variant="text" aria-label="Account and appearance settings">
<v-avatar color="primary" size="32">
<span class="text-caption font-weight-bold">{{ initials }}</span>
</v-avatar>
@@ -30,11 +32,68 @@
<v-select
v-model="localPreferences.theme"
:items="themeItems"
:item-props="themeItemProps"
item-title="title"
item-value="value"
label="Theme"
prepend-inner-icon="mdi-theme-light-dark"
/>
<v-select
v-model="localPreferences.fontScale"
:items="fontSizeItems"
:item-props="fontItemProps"
item-title="title"
item-value="value"
label="Text size"
prepend-inner-icon="mdi-format-size"
/>
<div class="text-caption quiet mt-1 mb-1">Accent colour</div>
<div class="accent-swatches" role="group" aria-label="Accent colour presets">
<button
type="button"
class="accent-swatch accent-default"
:class="{ selected: !localPreferences.accent }"
title="Theme default"
aria-label="Use theme default accent colour"
:aria-pressed="!localPreferences.accent"
@click="localPreferences.accent = ''"
>
<v-icon icon="mdi-cancel" size="16" />
</button>
<button
v-for="color in accentPresets"
:key="color"
type="button"
class="accent-swatch"
:class="{ selected: sameColor(localPreferences.accent, color) }"
:style="{ backgroundColor: color }"
:title="color"
:aria-label="`Accent colour ${color}`"
:aria-pressed="sameColor(localPreferences.accent, color)"
@click="localPreferences.accent = color"
/>
</div>
<v-text-field
v-model="localPreferences.accent"
label="Custom accent (hex)"
placeholder="#2457a6"
density="compact"
class="mt-2"
prepend-inner-icon="mdi-eyedropper-variant"
clearable
/>
<v-switch
v-model="localPreferences.contrast"
label="High-contrast boost"
color="primary"
density="compact"
hide-details
class="mt-1"
/>
<v-alert v-if="status" class="mt-2" density="compact" type="success">{{ status }}</v-alert>
</v-card-text>
<v-card-actions>
@@ -48,6 +107,8 @@
</template>
<script>
import { accentPresets, fontSizeItems } from '../constants';
export default {
props: {
preferences: { type: Object, required: true },
@@ -57,11 +118,14 @@ export default {
title: { type: String, required: true },
user: { type: Object, required: true }
},
emits: ['logout', 'save-preferences'],
emits: ['logout', 'open-help', 'save-preferences', 'toggle-nav'],
data() {
return {
accentPresets,
fontSizeItems,
localPreferences: { ...this.preferences },
profileMenu: false,
syncing: false,
status: ''
};
},
@@ -78,15 +142,32 @@ export default {
watch: {
preferences: {
handler(value) {
// Sync from parent without echoing back a live-preview emit (avoids a feedback loop).
this.syncing = true;
this.localPreferences = { ...value };
this.$nextTick(() => { this.syncing = false; });
},
deep: true
},
'localPreferences.theme'(theme) {
this.$emit('save-preferences', { ...this.localPreferences, theme }, { persist: false });
localPreferences: {
handler(value) {
if (this.syncing) return;
this.$emit('save-preferences', { ...value }, { persist: false });
},
deep: true
}
},
methods: {
themeItemProps(item) {
return { subtitle: item.props?.subtitle };
},
fontItemProps(item) {
return { subtitle: item.props?.subtitle };
},
sameColor(a, b) {
const norm = (value) => String(value || '').trim().toLowerCase().replace(/^#/, '');
return norm(a) !== '' && norm(a) === norm(b);
},
async save() {
await this.$emit('save-preferences', { ...this.localPreferences }, { persist: true });
this.status = 'Preferences saved';
@@ -97,3 +178,37 @@ export default {
}
};
</script>
<style scoped>
.accent-swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.accent-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid rgba(var(--v-theme-on-surface), 0.25);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: transform 0.08s ease, border-color 0.08s ease;
}
.accent-swatch:hover {
transform: scale(1.1);
}
.accent-swatch.selected {
border-color: rgb(var(--v-theme-on-surface));
box-shadow: 0 0 0 2px rgb(var(--v-theme-surface)), 0 0 0 4px rgb(var(--v-theme-primary));
}
.accent-swatch.accent-default {
background: transparent;
}
.accent-swatch:focus-visible {
outline: 3px solid rgb(var(--v-theme-info));
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,994 @@
<template>
<v-dialog :model-value="modelValue" max-width="1180" scrollable @update:model-value="$emit('update:modelValue', $event)">
<v-card class="user-manual-print" height="90vh">
<v-toolbar color="primary" density="comfortable" class="px-2">
<v-icon icon="mdi-book-open-page-variant" class="ml-2 mr-2" />
<v-toolbar-title class="font-weight-bold">MixedAssets User Guide</v-toolbar-title>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-printer" class="d-print-none" @click="print">Print / Save PDF</v-btn>
<v-btn icon="mdi-close" variant="text" class="d-print-none" @click="$emit('update:modelValue', false)" />
</v-toolbar>
<div class="manual-body">
<!-- Section navigation -->
<nav class="user-manual-nav">
<v-text-field
v-model="search"
density="compact"
hide-details
clearable
variant="solo-filled"
flat
placeholder="Search topics"
prepend-inner-icon="mdi-magnify"
class="ma-2"
/>
<v-list density="compact" nav class="pa-1">
<v-list-item
v-for="section in filteredSections"
:key="section.id"
:active="active === section.id"
:prepend-icon="section.icon"
:title="section.title"
rounded="sm"
@click="select(section.id)"
/>
<v-list-item v-if="!filteredSections.length" class="quiet text-body-2">No topics match {{ search }}.</v-list-item>
</v-list>
</nav>
<!-- Content -->
<div ref="content" class="manual-content user-manual-content">
<div class="manual-doc-title d-none d-print-block">
<h1>MixedAssets User Guide</h1>
<p class="quiet">Fixed-asset management, depreciation, maintenance &amp; integrations</p>
</div>
<!-- GETTING STARTED -->
<section v-show="isShown('overview')" class="manual-section">
<h2>Getting started</h2>
<p>
MixedAssets is an integrated system for tracking physical assets through their full lifecycle
acquisition, depreciation across multiple accounting books, preventative maintenance, disposal alongside
a parts inventory, a contacts/CRM directory, an alerting engine, and connectors to email, webhooks, and ServiceNow.
</p>
<p>Most day-to-day work follows one of these flows:</p>
<ul>
<li><strong>Record an asset</strong> set its books &amp; depreciation attach warranties, files, photos, and maintenance plans.</li>
<li><strong>Maintain it</strong> complete preventative-maintenance services, log tasks and notes, draw down spare parts.</li>
<li><strong>Account for it</strong> review the general ledger per book, run reports, and record disposals or impairments when it leaves service.</li>
</ul>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
You can reopen this guide at any time from the <strong>?</strong> icon in the top bar. Use the topic list on the
left, or the search box, to jump to a subject. The <strong>Print / Save PDF</strong> button produces a complete printable manual.
</v-alert>
<h3>Signing in</h3>
<p>
Sign in with your email and password. Your <em>role</em> determines which screens and actions are available
(see <a href="#" @click.prevent="select('roles')">Roles &amp; permissions</a>). If a screen is missing from your
left-hand menu, your role does not grant access to it.
</p>
</section>
<!-- INTERFACE -->
<section v-show="isShown('navigation')" class="manual-section">
<h2>Interface &amp; navigation</h2>
<h3>The left navigation rail</h3>
<p>
The rail on the left lists every screen your role can access. Some items expand to reveal sub-items:
<strong>Assets</strong> contains <strong>Scan</strong>, <strong>Assignments</strong>, and <strong>Templates</strong>; <strong>Maintenance</strong>
contains <strong>Parts inventory</strong>; and <strong>Financial</strong> contains <strong>Books</strong> and <strong>Tax rules</strong>.
<strong>Admin</strong> expands to <strong>Users &amp; Teams</strong>, <strong>Application Configuration</strong>, and <strong>Integrations</strong>.
Collapse the rail to icons using the chevron at the top of the rail
(or the menu button in the top bar) to give content more room; your choice is remembered on this device.
</p>
<h3>The top bar</h3>
<ul>
<li>Shows the current screen title and a short description.</li>
<li>Screen-specific actions (such as <strong>New asset</strong>) appear on the right.</li>
<li>The <strong>?</strong> icon opens this user guide.</li>
<li>Your avatar opens the profile menu for theme selection and sign-out.</li>
</ul>
<h3>Themes &amp; appearance</h3>
<p>
Open the profile menu (your avatar, top-right) to personalize the interface. All choices are saved to your account and
follow you between devices:
</p>
<ul>
<li><strong>Theme</strong> several dark options (Dark, Ocean, Slate, High contrast) and light options (Light, Sepia).</li>
<li><strong>Text size</strong> Small, Normal, Large, or Extra large; this scales the whole interface for easier reading.</li>
<li><strong>Accent colour</strong> pick a preset swatch or enter any custom hex value; choose default to return to the themes colour. Button text automatically switches to black or white for legibility.</li>
<li><strong>High-contrast boost</strong> strengthens borders, text, and link underlines on top of whichever theme youre using.</li>
</ul>
<h3>Accessibility</h3>
<p>MixedAssets is built to work with assistive technology:</p>
<ul>
<li>Press <strong>Tab</strong> to reveal a Skip to main content link, and to move through controls with a clear keyboard focus outline.</li>
<li>Images and photos carry descriptive text for screen readers, and icon-only buttons have spoken labels.</li>
<li>The text-size and high-contrast options above support low-vision use, and the app honours your systems reduce motion setting.</li>
</ul>
<h3>Tables</h3>
<p>Most lists use a consistent table with the same controls everywhere:</p>
<ul>
<li><strong>Filter box</strong> type to narrow the rows.</li>
<li><strong>Sortable columns</strong> click a header to sort; click again to reverse, a third time to clear.</li>
<li><strong>Rows per page</strong> and pagination at the bottom; choose <em>All</em> to show everything.</li>
<li>Headers stay visible (sticky) while you scroll long lists.</li>
</ul>
</section>
<!-- DASHBOARD -->
<section v-show="isShown('dashboard')" class="manual-section">
<h2>Dashboard</h2>
<p>
The Dashboard is your portfolio at a glance. It summarizes the number of assets, total cost, how many are in
service versus disposed, and a chart of value by category, with a supporting breakdown table beneath it.
It also surfaces recent activity and upcoming work so you can see what needs attention without opening each screen.
</p>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
The Dashboard is read-only it is a reporting surface. Make changes from the relevant screen (Assets, Maintenance, etc.)
and the Dashboard updates automatically.
</v-alert>
</section>
<!-- ASSETS -->
<section v-show="isShown('assets')" class="manual-section">
<h2>Assets creating &amp; managing</h2>
<p>
The <strong>Asset register</strong> lists every asset. Use the filter and status selector to find records, select
rows for bulk actions, and open any asset to edit it in the <em>asset workbench</em> (the panel that slides in from the right).
</p>
<h3>Creating an asset</h3>
<ol>
<li>Click <strong>New asset</strong> in the top bar.</li>
<li>Optionally pick a <strong>Template</strong> to pre-fill category, life, method, GL accounts, and custom fields.</li>
<li>Fill in the <strong>Details</strong>: description, category, acquisition cost, acquired / in-service dates, useful life, location, custodian, serial number, and so on.</li>
<li>Click <strong>Save asset</strong>. The workbench stays open on the new record so you can immediately add books, files, photos, warranties, and maintenance.</li>
</ol>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
An Asset ID is generated automatically if you leave it blank. You can also type your own identifier.
</v-alert>
<h3>The asset workbench tabs</h3>
<table class="manual-table">
<thead><tr><th>Tab</th><th>What its for</th></tr></thead>
<tbody>
<tr><td><strong>Details</strong></td><td>Core asset data, GL accounts, condition, and the barcode label (print a single label here).</td></tr>
<tr><td><strong>Books</strong></td><td>Per-book depreciation settings each book can carry its own cost, method, life, §179, bonus, and convention. See <a href="#" @click.prevent="select('books')">Books, depreciation &amp; tax</a>.</td></tr>
<tr><td><strong>Files</strong></td><td>Attach invoices, manuals, and insurance records. Files are stored with the asset and can be downloaded later.</td></tr>
<tr><td><strong>Photos</strong></td><td>Identification photos what the asset looks like, where it is, nameplates and serial labels. Each photo can have a caption. On a phone, <strong>Add photos</strong> opens the camera.</td></tr>
<tr><td><strong>Warranties</strong></td><td>Provider, coverage window, limits, and details. Expiring warranties raise alerts.</td></tr>
<tr><td><strong>Tasks</strong></td><td>Ad-hoc to-dos (inspection, calibration, renewal). Overdue tasks raise alerts. Tick a task to complete it.</td></tr>
<tr><td><strong>Notes</strong></td><td>A running, timestamped log of comments about the asset.</td></tr>
<tr><td><strong>Disposal</strong></td><td>Record a sale/retirement and impairments see <a href="#" @click.prevent="select('lifecycle')">Asset lifecycle</a>.</td></tr>
<tr><td><strong>Lease</strong></td><td>Capture lease terms and view the amortization schedule see <a href="#" @click.prevent="select('lifecycle')">Asset lifecycle</a>.</td></tr>
<tr><td><strong>PM</strong></td><td>Attach preventative-maintenance plans and complete services see <a href="#" @click.prevent="select('pm')">Preventative maintenance</a>.</td></tr>
</tbody>
</table>
<h3>Editing &amp; bulk changes</h3>
<ul>
<li>Open any row to edit it, then <strong>Save asset</strong>.</li>
<li>Select multiple rows and use <strong>mass edit</strong> to change status, location, or department across all of them at once.</li>
<li><strong>Import</strong> a spreadsheet to create/update many assets, and <strong>export</strong> the register to a file.</li>
</ul>
<h3>Status values</h3>
<p>
Assets move through statuses such as <em>in service</em>, <em>CIP</em> (construction in progress), <em>reserved</em>,
<em>checked out</em>, <em>disposed</em>, and <em>retired</em>. Status drives filtering, the Dashboard counts, and which
records appear in reports.
</p>
</section>
<!-- LIFECYCLE -->
<section v-show="isShown('lifecycle')" class="manual-section">
<h2>Asset lifecycle disposal, leases &amp; impairment</h2>
<h3>Disposing of an asset</h3>
<p>Open the asset, go to the <strong>Disposal</strong> tab, and:</p>
<ol>
<li>Choose the <strong>book</strong> the disposal is calculated against, the <strong>disposal type</strong> (sale, exchange, abandonment, like-kind, involuntary conversion), and the date.</li>
<li>Enter the sale price and any selling expense. For a partial disposal, tick <strong>Partial</strong> and enter the cost basis being removed.</li>
<li>Click <strong>Calculate gain/loss</strong> to preview accumulated depreciation, net book value, proceeds, and the resulting gain or loss.</li>
<li>Click <strong>Record disposal</strong> to finalize. The asset is marked disposed and the entry appears in the disposal history (you can reverse it from there if needed).</li>
</ol>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
Gain/loss is computed from the selected books net book value at the disposal date, so the figure reflects that books
method and conventions. Disposals also feed the disposal and gain/loss reports.
</v-alert>
<h3>Impairments &amp; adjustments</h3>
<p>
Below the disposal form, record an <strong>impairment</strong>, write-down, write-up, or revaluation against a specific
book with an amount, date, and reason. These adjust the assets carrying value and appear in the adjustments report.
</p>
<h3>Leases</h3>
<p>
On the <strong>Lease</strong> tab, capture the lessor, total contract value, start/end dates, discount rate, and payment
frequency. Select a saved lease to view its <strong>amortization schedule</strong> present value (right-of-use asset),
periodic payment, and the period-by-period split of interest, principal, and remaining liability. Leases nearing their
end date raise alerts.
</p>
</section>
<!-- BARCODES -->
<section v-show="isShown('barcodes')" class="manual-section">
<h2>Barcodes &amp; scanning</h2>
<h3>Printing labels</h3>
<p>
Every asset can carry a barcode value (it falls back to the Asset ID if left blank). Print a single label from the
assets <strong>Details</strong> tab, or select multiple assets in the register and print a <strong>label sheet</strong> for batch tagging.
</p>
<h3>The Scan screen</h3>
<p>
Use <strong>Scan</strong> to find an asset in the field: point your device camera at a barcode (or type the code), and the
matching asset opens so you can update its status, location, department, custodian, and condition, or add a quick note on the spot.
</p>
<v-alert type="warning" variant="tonal" density="comfortable" class="manual-callout">
Camera scanning requires a secure (HTTPS) connection or running on the local machine. Where the camera isnt available,
manual code entry works everywhere.
</v-alert>
</section>
<!-- BOOKS / DEPRECIATION -->
<section v-show="isShown('books')" class="manual-section">
<h2>Books, depreciation &amp; tax</h2>
<p>
A <strong>book</strong> is a parallel set of depreciation figures for example GAAP (financial statements), Federal tax,
State, AMT, ACE, and your own user-defined books. The same physical asset is depreciated independently in each book, so you
can satisfy financial reporting and several tax regimes from one record.
</p>
<h3>How the financial modules relate to asset tracking</h3>
<ul>
<li><strong>Books screen</strong> defines the <em>registry</em> of books available across the whole system: which are enabled, each books default method and convention, and which tax rule set it uses.</li>
<li><strong>Asset Books tab</strong> every enabled book appears here for each asset, where you can override cost, life, method, §179, bonus, and convention <em>per book, per asset</em>.</li>
<li><strong>Tax rule sets</strong> supply the catalog of depreciation methods and the statutory limits (e.g. §179 caps, bonus %) that a book draws on. Assigning a rule set to a book is what makes that book follow a given jurisdictions rules.</li>
<li><strong>Depreciation engine</strong> calculates each book using its assigned rule set; the results drive the general ledger, reports, and disposal gain/loss.</li>
</ul>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
In short: <em>Tax rule set assigned to a Book applied per Asset produces depreciation rolls up to the GL and reports.</em>
</v-alert>
<h3>Configuring books</h3>
<p>On the <strong>Books</strong> screen you can:</p>
<ul>
<li>Enable/disable books, set each books default method and convention, and assign its tax rule set.</li>
<li><strong>Create a user-defined book</strong> (e.g. IFRS or an internal management book) with the <strong>New book</strong> button. Newly enabled books automatically appear on every assets Books tab.</li>
<li>Delete a book that isnt a primary or maintenance book and isnt in use.</li>
</ul>
<h3>The general ledger view</h3>
<p>
Select a book to view its <strong>GL ledger</strong> in debit/credit format asset cost, accumulated depreciation, and the
periods depreciation expense rolled to GL accounts, with per-asset detail and a summary. You can export the ledger.
</p>
<h3>Depreciation methods &amp; critical fields</h3>
<p>
Each books depreciation is computed from the depreciation-critical fields: property type, placed-in-service date,
acquisition value, depreciation method, estimated life, salvage value, Section 168 (bonus) allowance %, Section 179, and
business-use %. Supported 200% declining-balance methods include <strong>MF200</strong> (MACRS formula),
<strong>MT200</strong> (MACRS tables), and <strong>MI200</strong> (MACRS, Indian-reservation) which ignore salvage and
recover the full basis and <strong>DB200</strong>, <strong>DH200</strong> (half-year), and <strong>DD200</strong> (full-year),
which honor salvage (depreciating only down to it). All switch to straight-line when that yields a larger deduction.
</p>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
When you change a depreciation-critical field on an asset that already has depreciation, a prompt asks <strong>when to apply</strong>
the change: <em>Placed-in-service date</em> (rebuild the whole schedule) or <em>Current period going forward</em> (keep depreciation
already recorded).
</v-alert>
<h3>Special depreciation zones</h3>
<p>
Assign a <strong>Special depreciation zone</strong> on the assets Details tab (e.g. <strong>New York Liberty Zone</strong>) and the
engine automatically applies the zones §168 special allowance (30% for NY Liberty) to the <strong>federal book</strong> when the
assets placed-in-service date falls within the zones window. The form shows a note confirming the allowance, the date window,
and any leasehold-improvement life. Zones are managed in <strong>Admin Application Configuration Depreciation zones</strong>
(create your own, set the allowance %, date window, and notes).
</p>
<h3>The PM (maintenance) book</h3>
<p>
A dedicated <strong>PM book</strong> tracks actual maintenance <em>spend</em> per asset rather than depreciation. Its ledger
aggregates the cost captured when preventative-maintenance services are completed (see <a href="#" @click.prevent="select('pm')">Preventative maintenance</a>).
</p>
<h3>Reports</h3>
<p>
The <strong>Reports</strong> screen is catalog-driven: choose a report, set parameters (book, year, etc.), and view a table
with totals and an optional chart. Reports cover depreciation schedules and expense, net book value, roll-forwards,
acquisitions, disposals and gain/loss, adjustments, journal entries, several tax forms, and maintenance/PM and alert
reports. You can save report configurations to rerun later and export results to PDF, Excel, or CSV. To print an individual
PM plan, run the PM plan report and export it.
</p>
</section>
<!-- TAX RULES -->
<section v-show="isShown('taxrules')" class="manual-section">
<h2>Tax rule sets</h2>
<p>
A <strong>tax rule set</strong> defines the depreciation methods and statutory limits for a jurisdiction
(for example US federal, or a particular state). A book consults its assigned rule set to compute depreciation.
</p>
<h3>The Tax rules editor</h3>
<p>The screen has two parts: <strong>identity fields</strong> on the left (Name, Jurisdiction, Version, Effective date, Source note, Active) and a <strong>JSON editor</strong> holding the rule body. The toolbar offers:</p>
<ul>
<li><strong>New</strong> / <strong>Upload JSON</strong> start a fresh set or load one from a file.</li>
<li><strong>Format</strong> validate and tidy (pretty-print) the JSON. <em>Save is disabled while the JSON is invalid.</em></li>
<li><strong>Regenerate method catalog</strong> auto-fill the full set of standard depreciation methods, so you rarely have to type the <code>methods</code> list by hand.</li>
<li><strong>Export</strong> download the set as a JSON file.</li>
<li><strong>Activate</strong> make this version the one in effect for its jurisdiction (this deactivates other versions for the same jurisdiction).</li>
<li><strong>Save</strong> / <strong>Delete</strong>.</li>
</ul>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
The fastest way to build a set: click <strong>New</strong>, fill the identity fields, click <strong>Regenerate method catalog</strong> to populate the standard methods, adjust the <code>limits</code> for your jurisdiction, then <strong>Format</strong>, <strong>Save</strong>, and <strong>Activate</strong>. Finally, assign it to a book on <strong>Financial Books</strong>.
</v-alert>
<h3>A quick JSON primer</h3>
<p>The rule body is written in JSON. A few rules cover almost everything you need:</p>
<ul>
<li>The whole document is an <strong>object</strong> wrapped in curly braces.</li>
<li>Inside, you list <strong>"key": value</strong> pairs, separated by commas.</li>
<li>Keys and text values are wrapped in <strong>double quotes</strong> <code>"like this"</code>.</li>
<li>Numbers (<code>60</code>, <code>0.15</code>) and <code>true</code> / <code>false</code> / <code>null</code> are written <strong>without</strong> quotes.</li>
<li>A <strong>list</strong> uses square brackets, with items separated by commas. Objects and lists can nest inside each other.</li>
<li>Do <strong>not</strong> put a comma after the last item in an object or list a trailing comma is the most common mistake. Use <strong>Format</strong> to catch errors.</li>
</ul>
<h3>Top-level fields</h3>
<table class="manual-table">
<thead><tr><th>Field</th><th>Type</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td>schema</td><td>text</td><td>Optional tag identifying the file format.</td></tr>
<tr><td>jurisdiction</td><td>text</td><td>The jurisdiction this set governs (e.g. <code>US-FED</code>, <code>US-CA</code>). Also set by the identity field.</td></tr>
<tr><td>name</td><td>text</td><td>Display name for the rule set.</td></tr>
<tr><td>version</td><td>text</td><td>Version label; must be unique within a jurisdiction.</td></tr>
<tr><td>effectiveDate</td><td>date text</td><td>When the rules take effect, as <code>"YYYY-MM-DD"</code>.</td></tr>
<tr><td>sourceNote</td><td>text</td><td>Free-form note about the source or authority.</td></tr>
<tr><td>books</td><td>list of text</td><td>Reference list of the books this set is intended for.</td></tr>
<tr><td>conventions</td><td>list of text</td><td>Conventions in use (reference): <code>half_year</code>, <code>mid_quarter</code>, <code>mid_month</code>, <code>full_month</code>, <code>none</code>.</td></tr>
<tr><td>limits</td><td>object</td><td>Statutory reference limits §179, bonus, de-minimis (see below).</td></tr>
<tr><td>assetLives</td><td>list of objects</td><td>Reference list of class lives, each <code>{ code, label, months }</code>.</td></tr>
<tr><td>methods</td><td>list of objects</td><td><strong>The calculation catalog the engine actually uses</strong> (see below).</td></tr>
</tbody>
</table>
<p class="quiet">Name, jurisdiction, version, effective date, source note, and active status are also editable in the identity fields beside the editor; keeping them in the JSON too is fine.</p>
<h4>The <code>limits</code> object</h4>
<ul>
<li><strong>section179</strong>: <code>deductionLimit</code> (number), <code>phaseOutBegins</code> (number), <code>cannotCreateLoss</code> (true/false).</li>
<li><strong>bonusDepreciation</strong>: <code>qualifiedPropertyPercent</code> (e.g. <code>100</code>), <code>effectivePlacedInServiceAfter</code> (date text).</li>
<li><strong>deMinimisSafeHarbor</strong>: <code>defaultThreshold</code> (number), <code>auditedFinancialStatementThreshold</code> (number).</li>
</ul>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
These limits <em>document</em> the jurisdictions caps for reference. The actual §179 and bonus amounts applied to a given
asset are the values you enter on that assets <strong>Books</strong> tab.
</v-alert>
<h3>Inside <code>methods</code> the calculation catalog</h3>
<p>
Each entry in <code>methods</code> is one depreciation method. When you pick a method on an assets Books tab, the engine
matches your choice by its <code>code</code> and computes depreciation using that entrys settings.
</p>
<table class="manual-table">
<thead><tr><th>Field</th><th>Type</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td>code</td><td>text</td><td>Unique identifier; this is what a books method selection points to.</td></tr>
<tr><td>family</td><td>text</td><td>Grouping shown in the picker e.g. <code>MACRS</code>, <code>ACRS</code>, <code>GAAP</code>, <code>Manual</code>, <code>Lease</code>.</td></tr>
<tr><td>label</td><td>text</td><td>Human-readable name shown in pickers and reports.</td></tr>
<tr><td>formula</td><td>text</td><td>How depreciation is calculated (see values below).</td></tr>
<tr><td>lifeMonths</td><td>number</td><td>Recovery period in months (e.g. <code>60</code> = 5 years).</td></tr>
<tr><td>defaultConvention</td><td>text</td><td>First/last-year convention this method applies (overrides the books convention).</td></tr>
<tr><td>rateMultiplier</td><td>number</td><td>Declining-balance rate: <code>2</code> = 200%, <code>1.5</code> = 150%.</td></tr>
<tr><td>switchToStraightLine</td><td>true/false</td><td>For declining balance, switch to straight-line once it yields a larger deduction (typical for MACRS).</td></tr>
<tr><td>rates</td><td>list of numbers</td><td>For the <code>rate_table</code> formula: the per-year fractions of basis (should total 1.0).</td></tr>
<tr><td>fallbackFormula</td><td>text</td><td>Used when a rate table is empty (e.g. <code>straight_line</code>).</td></tr>
<tr><td>system</td><td>text</td><td>Optional label such as <code>GDS</code>, <code>ADS</code>, or <code>ACRS</code>.</td></tr>
</tbody>
</table>
<h4>Accepted <code>formula</code> values</h4>
<table class="manual-table">
<thead><tr><th>Value</th><th>Behavior</th></tr></thead>
<tbody>
<tr><td><code>straight_line</code></td><td>Even amount over the life. Used as the fallback for any unrecognized formula.</td></tr>
<tr><td><code>declining_balance</code></td><td>Accelerated, using <code>rateMultiplier</code>.</td></tr>
<tr><td><code>macrs_declining_balance</code></td><td>Declining balance that switches to straight-line (the standard MACRS behavior).</td></tr>
<tr><td><code>sum_of_years_digits</code></td><td>Accelerated sum-of-years-digits.</td></tr>
<tr><td><code>rate_table</code></td><td>Applies the per-year fractions in <code>rates</code>.</td></tr>
<tr><td><code>acrs_alternate_straight_line</code></td><td>ACRS alternate (modified) straight-line.</td></tr>
<tr><td><code>manual</code></td><td>You enter each years amount on the asset (no automatic calculation).</td></tr>
</tbody>
</table>
<h3>Sample rule set</h3>
<p>A compact example showing the three most common method styles straight-line, declining balance, and a rate table:</p>
<pre class="manual-code">{{ sampleRuleSet }}</pre>
<v-alert type="warning" variant="tonal" density="comfortable" class="manual-callout">
Rule sets ship as starter templates. Always confirm current tax law, rates, and elections before relying on a set for a filing.
</v-alert>
<p>To put a set to work, open <strong>Financial Books</strong> and assign it to a book.</p>
</section>
<!-- TEMPLATES -->
<section v-show="isShown('templates')" class="manual-section">
<h2>Templates</h2>
<p>
Templates speed up asset entry and standardize data. A template defines default values (category, useful life,
depreciation method, GL accounts, business-use %, etc.) and a set of <strong>custom fields</strong> (text, number, date, or
checkbox) that should be captured for that kind of asset.
</p>
<h3>Creating a template</h3>
<ol>
<li>Open <strong>Templates</strong> and give the template a name and description.</li>
<li>Set the default values new assets should start with.</li>
<li>Add custom fields, marking any that are required.</li>
<li>Save. The template is now selectable from the <strong>Template</strong> picker when creating an asset.</li>
</ol>
<p>Choosing a template on a new asset fills the defaults and adds its custom fields to the Details tab.</p>
<h3>Editing &amp; deleting templates</h3>
<p>
The templates list (right) is searchable and paginated, and shows how many fields each template has and how many
assets currently use it. Use the <strong>pencil</strong> to load a template back into the editor and save your changes,
or the <strong>trash</strong> to delete it.
</p>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
Deleting a template is safe for your data: assets that used it <strong>keep all their values</strong> and are simply
unlinked from the template. The only other effect is that the template is no longer offered when creating new assets.
The confirmation dialog tells you how many assets are affected before you proceed.
</v-alert>
</section>
<!-- PM -->
<section v-show="isShown('pm')" class="manual-section">
<h2>Preventative maintenance</h2>
<p>
The <strong>Maintenance</strong> screen manages reusable <strong>PM plans</strong> standard procedures performed on a
schedule which you then attach to specific assets and complete over time.
</p>
<h3>Creating a PM plan</h3>
<ol>
<li>On <strong>Maintenance</strong>, click <strong>New plan</strong>.</li>
<li>Name the plan and set its <strong>frequency</strong> (every N days, weeks, months, quarters, bi-yearly, or years).</li>
<li>Add ordered <strong>steps</strong> (the checklist a technician follows), each with an optional time estimate the plans total estimated time totals automatically.</li>
<li>Add <strong>components / parts</strong> (part number, description, supplier, cost) to capture expected materials and cost.</li>
<li>Add <strong>guidance photos</strong> (diagrams, part locations, before/after) with captions these are shown to the technician while completing the service.</li>
<li>Save the plan.</li>
</ol>
<h3>Attaching a plan to an asset</h3>
<p>
Open the asset, go to the <strong>PM</strong> tab, choose a plan, and set the interval, first-due date, and an end date
(it pre-fills to the assets depreciation end). The schedule now tracks the next due date and raises alerts as it approaches or passes.
</p>
<h3>Completing (closing) a service</h3>
<ol>
<li>On the assets <strong>PM</strong> tab, click <strong>Complete</strong> on an active schedule.</li>
<li>The form records who is closing it (you), shows any <strong>guidance photos</strong>, and lets you tick off each step.</li>
<li>Give 15 <strong>condition ratings</strong>: Safety (is it safe?), Physical condition, and Operating condition.</li>
<li>Add <strong>completion notes</strong> and <strong>photos</strong> of the work, confirm the parts cost, and capture a <strong>signature</strong> (required).</li>
<li>Click <strong>Mark complete</strong>. The next-due date rolls forward automatically (the schedule closes if it has passed its end date), and the cost is recorded to the PM book.</li>
</ol>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
Completed forms with signature, ratings, notes, and photos are viewable from both the assets PM tab and the
<strong>Completed PM forms</strong> list on the Maintenance screen.
</v-alert>
</section>
<!-- PARTS -->
<section v-show="isShown('parts')" class="manual-section">
<h2>Parts inventory</h2>
<p>
<strong>Parts inventory</strong> (under Maintenance) is a standalone stockroom for maintenance parts and supplies. It is
deliberately separate from the asset register parts are consumables, not depreciating assets.
</p>
<h3>Adding a part</h3>
<ol>
<li>Open <strong>Maintenance Parts inventory</strong> and click <strong>New part</strong>.</li>
<li>Enter the part number, name, category, unit of measure, unit cost, and a reorder point (used for low-stock flagging).</li>
<li>Add identification details measurements, manufacturer part number, barcode, and photos.</li>
<li>Pick a <strong>supplier</strong> from your Contacts (vendors, service providers, or contractors).</li>
<li>Save, then add stock locations and movements.</li>
</ol>
<h3>Stock, locations &amp; reservations</h3>
<ul>
<li>Store a part in one or more <strong>locations</strong> (chosen from your Work Location contacts), each with an <strong>aisle</strong> and <strong>bin</strong>.</li>
<li>Each location tracks quantity on hand and how much is <strong>reserved</strong>; available = on hand reserved.</li>
<li>Record <strong>movements</strong>: receive (add stock), issue (consume), adjust (set an absolute count), reserve, and unreserve. Every movement is logged with history.</li>
<li>Parts at or below their reorder point are flagged <strong>low stock</strong>; use the low-stock filter to see what to reorder.</li>
</ul>
</section>
<!-- CONTACTS -->
<section v-show="isShown('contacts')" class="manual-section">
<h2>Contacts &amp; CRM</h2>
<p>
<strong>Contacts</strong> is a directory of the people and organizations you work with: <em>employees, vendors, service
providers, contractors, work locations,</em> and <em>other</em>. The form adapts to the type you choose.
</p>
<h3>Managing contacts</h3>
<ul>
<li>Use the type selector and filter box to find records; create a contact with <strong>New contact</strong>.</li>
<li>Capture name/organization, international-friendly address and phone, and email.</li>
<li><strong>Type-specific fields</strong>: employee ID/department/manager/start date; contractor tax ID or business license; vendor and service-provider star rating and credit term.</li>
<li>Keep a per-contact <strong>notes log</strong> for running history.</li>
</ul>
<h3>How contacts connect to the rest of the app</h3>
<ul>
<li><strong>Work location</strong> contacts populate the <strong>Location</strong> dropdown on the asset form and are the locations where parts stock is held.</li>
<li><strong>Vendor / service-provider / contractor</strong> contacts populate the asset forms <strong>Vendor</strong> dropdown and are selectable as the <strong>supplier</strong> on parts.</li>
<li>Employee records can be brought in automatically from Workday when that connector is configured (see <a href="#" @click.prevent="select('admin')">Administration</a>).</li>
</ul>
</section>
<!-- ASSIGNMENTS -->
<section v-show="isShown('assignments')" class="manual-section">
<h2>Assignments (custody)</h2>
<p>
<strong>Assignments</strong> tracks who has custody of which asset. Assign an asset to an employee, department, or location,
and the full custody history is retained so you always know where an asset is and who is responsible for it.
</p>
<ul>
<li>The employee list is filterable and paginated.</li>
<li>Assign an asset, then <strong>release</strong> it (with a reason) when custody changes both events are kept in the traceability history.</li>
<li>Employees can be added here directly, or synced from Workday.</li>
</ul>
</section>
<!-- ALERTS -->
<section v-show="isShown('alerts')" class="manual-section">
<h2>Alerts &amp; reminders</h2>
<p>
The <strong>Alerts</strong> screen surfaces everything that needs attention: overdue and upcoming tasks, preventative
maintenance due/overdue, and warranties or leases about to expire. Each alert has a <strong>severity</strong> <em>critical</em>
(overdue/expired, or within seven days) or <em>warning</em>.
</p>
<h3>Working alerts</h3>
<ul>
<li><strong>Run scan</strong> recalculates the current alerts from your data.</li>
<li><strong>Acknowledge</strong> an alert to mark it in-progress; <strong>Dismiss</strong> one to stop it reappearing.</li>
<li>Filter by status and type, and use the summary tiles for a quick count of open and critical items.</li>
</ul>
<h3>Delivery channels</h3>
<p>Beyond the on-screen list, alerts can be pushed outward (all configured in <a href="#" @click.prevent="select('admin')">Administration</a>):</p>
<ul>
<li><strong>Email digest</strong> when email is enabled and a mail server is configured, new alerts are emailed to the recipient list. You can also trigger <strong>Email digest now</strong> from the Alerts screen.</li>
<li><strong>Webhook</strong> each new alert can be posted to an external URL as a JSON object, optionally signed for authenticity. Useful for chat tools or custom automation.</li>
<li><strong>ServiceNow incident</strong> raise a ticket from any alert (see below).</li>
</ul>
<h3>Forwarding an alert to ServiceNow</h3>
<p>
When the ServiceNow connection is configured, each open alert shows a <strong>ServiceNow</strong> action. Click it to create
an incident; the alerts <strong>Ticket</strong> column then shows the incident number as a link straight into ServiceNow.
Re-clicking returns the existing ticket rather than creating a duplicate. With <em>auto-create</em> enabled, the system raises
an incident automatically for each new <em>critical</em> alert. Alert severity maps to the incidents impact/urgency.
See <a href="#" @click.prevent="select('servicenow')">ServiceNow integration</a> for setup.
</p>
</section>
<!-- SERVICENOW -->
<section v-show="isShown('servicenow')" class="manual-section">
<h2>ServiceNow integration</h2>
<p>
MixedAssets connects to ServiceNow in two directions, both configured in <strong>Administration ServiceNow integration</strong>:
pushing <strong>incidents</strong> out, and pulling <strong>asset data</strong> in from the CMDB.
</p>
<h3>Connecting</h3>
<ol>
<li>Enter your ServiceNow <strong>instance URL</strong> (e.g. <em>https://yourcompany.service-now.com</em>) and the username/password of an integration user.</li>
<li>Click <strong>Test connection</strong> to confirm the credentials work.</li>
<li>Save. The stored password is never displayed back; leave it blank to keep the existing value, or use <strong>Clear password</strong> to remove it.</li>
</ol>
<h3>Pushing incidents (tickets)</h3>
<ul>
<li>Turn on <strong>Allow submitting alerts as incidents</strong> to enable ticket creation.</li>
<li>Optionally turn on <strong>Auto-create incidents for new critical alerts</strong> so critical items raise tickets automatically.</li>
<li>Set the incident table (usually <em>incident</em>) and, if you wish, a default assignment group and caller.</li>
<li>Tickets are then raised from the <strong>Alerts</strong> screen, with the incident number linked back to ServiceNow.</li>
</ul>
<h3>Pulling assets from the CMDB</h3>
<p>This populates your asset register from ServiceNow configuration items (CIs):</p>
<ol>
<li>Set the <strong>CMDB CI table</strong> (for example <em>cmdb_ci_hardware</em>) and, optionally, a filter to limit which CIs are pulled and a maximum number per sync.</li>
<li>Review the <strong>field mapping</strong> which CI fields populate which asset fields. Sensible hardware defaults are provided; adjust as needed.</li>
<li>Click <strong>Sync CMDB now</strong>. The last sync time and a result summary are shown.</li>
</ol>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
Re-syncing is safe: existing assets are matched (by their ServiceNow identifier, then serial number, then asset tag) and
<em>updated in place</em> rather than duplicated. CMDB-sourced assets receive default depreciation books like any other asset,
which you can then refine on the Books tab.
</v-alert>
</section>
<!-- ADMIN -->
<section v-show="isShown('admin')" class="manual-section">
<h2>Administration</h2>
<p>
<strong>Admin</strong> (admin role) is where the application is configured. It is split into three sub-screens in the
navigation rail: <strong>Users &amp; Teams</strong>, <strong>Application Configuration</strong> (application settings, asset classes, depreciation zones, asset ID templates, asset categories,
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and <strong>Integrations</strong> (email, webhook, ServiceNow, Workday). Each is described below.
</p>
<h3>Users &amp; roles</h3>
<ul>
<li>Create and edit users name, email, team, role, status, and password.</li>
<li>The list paginates and is filterable. A users role controls what they can see and do.</li>
</ul>
<h3>Teams</h3>
<p>Group users into teams. Select a team to edit or delete it (a team in use by members cant be deleted until theyre reassigned).</p>
<h3>Roles</h3>
<p>Create and edit roles and the capabilities they grant including custom roles. See <a href="#" @click.prevent="select('roles')">Roles &amp; permissions</a>.</p>
<h3>Application settings</h3>
<p>Server name, allowed cross-origin domains, and the database driver/path (read-only) used by this deployment.</p>
<h3>Asset ID templates</h3>
<p>
Define naming patterns that auto-assign asset tags. Put <strong>#</strong> characters where the running number goes
its zero-padded to that width and increments automatically. For example <code>DT-######</code> produces DT-000001,
DT-000002, and <code>T-A#####</code> produces T-A00001. Each template has a next number you can set (e.g. to
continue an existing sequence), and a live preview of the next tag.
</p>
<p>
Assign a template to a category in <strong>Asset categories</strong> (below). Then, when someone creates an asset and
picks that category, the <strong>Asset ID is filled in automatically</strong> from the template they can still type
their own ID to override it. Categories with no template use the default <code>MA-#####</code> sequence. Deleting a
template never renames existing assets; it just unassigns the affected categories.
</p>
<h3>Asset classes</h3>
<p>
A managed catalog of IRS asset classes (class number, GDS/ADS recovery periods), pre-loaded from the IRS tables
commonly-used assets, manufacturing industry, and business activity. <strong>Create, edit, and delete</strong> entries here;
filter by source or search by description/class number. These power the Look up IRS asset class search when you set a
categorys class number. Deleting a class never changes assets or categories it only removes it from the lookup.
</p>
<h3>Asset categories</h3>
<p>
Create, rename, and delete the categories offered in the asset forms <strong>Category</strong> dropdown so users
choose from a controlled list instead of typing free text. The list is searchable and paginated and shows how many
assets use each category, plus the <strong>ID template</strong> assigned to it (chosen when you create or edit a category).
</p>
<ul>
<li><strong>Renaming</strong> a category automatically updates every asset that uses it, so its also the way to merge two categories (rename one to match the other).</li>
<li><strong>Deleting</strong> a category leaves existing assets values untouched; the category simply stops appearing in the dropdown. The confirmation tells you how many assets are affected first.</li>
<li><strong>Asset class number</strong> an optional number on the category that is <strong>shared by every asset in it</strong>. New assets inherit it automatically, and changing it cascades to all existing assets in the category. It appears (read-only) on each assets Details tab. Use the built-in <strong>Look up IRS asset class</strong> search to pick from the IRS tables <em>commonly-used assets</em>, <em>manufacturing industry</em>, and <em>business activity</em>; each result is tagged Common, Manufacturing, or Business and shows its GDS/ADS recovery periods. You can also type a custom number.</li>
</ul>
<h3>Asset departments</h3>
<p>
Works exactly like Asset categories, but for the asset forms <strong>Department</strong> dropdown create, rename
(cascades to assets, and merges by renaming to match), and delete (assets keep their value; it just leaves the dropdown).
</p>
<h3>PM categories</h3>
<p>
The same management, but for the <strong>Category</strong> dropdown on preventative-maintenance plans and kept
<strong>separate from asset categories</strong>. Renaming cascades to PM plans; deleting leaves existing plans values
and removes the option from the dropdown.
</p>
<h3>Parts categories</h3>
<p>
The same management again, for the <strong>Category</strong> dropdown on parts inventory separate from asset and PM
categories, and pre-loaded with a starter set (Electrical, Plumbing, Structural Component, Electronic Component, Fire
Safety, Mechanical Safety, Other). Renaming cascades to parts; deleting leaves existing parts values.
</p>
<h3>Email &amp; alerts (SMTP)</h3>
<ul>
<li>Turn email alerts on/off, set how many days ahead to warn, and the recipient list.</li>
<li>Configure the mail server (host, port, security, username, from-address). The password is write-only blank keeps the stored value.</li>
<li>Use <strong>Send test</strong> to verify delivery.</li>
</ul>
<h3>Alert webhook</h3>
<p>
Enable webhook delivery and set the destination URL to have each new alert posted as a JSON object. An optional signing
secret adds a verification signature to each request. <strong>Send test event</strong> confirms the endpoint works, and a sample
payload is shown for whoever builds the receiving side.
</p>
<h3>ServiceNow integration</h3>
<p>Connection, incident push, and CMDB pull settings covered in detail under <a href="#" @click.prevent="select('servicenow')">ServiceNow integration</a>.</p>
<h3>Maintenance (PM) defaults</h3>
<p>Set the default maintenance frequency for newly attached plans and how many days ahead PM-due alerts should appear.</p>
<h3>Workday connector</h3>
<ul>
<li>Configure the connection to your Workday tenant to sync employees, departments, and locations.</li>
<li>Run a manual <strong>Sync workers</strong>, or enable <strong>automatic scheduled sync</strong> and set the interval in hours.</li>
<li>Synced employees populate both the assignments employee list and the Contacts directory (with manager and start-date enrichment).</li>
</ul>
<h3>Tax rule sets</h3>
<p>A quick reference list of the rule sets in the system; manage them fully on the <a href="#" @click.prevent="select('taxrules')">Tax rules</a> screen.</p>
</section>
<!-- ROLES -->
<section v-show="isShown('roles')" class="manual-section">
<h2>Roles &amp; permissions</h2>
<p>
Every user has one role, and a role is a set of <strong>capabilities</strong> (granular permissions such as Create &amp; edit
assets or Manage integrations). Capabilities decide which screens appear and which actions are allowed. Roles are managed
in <strong>Admin Users &amp; Teams Roles</strong>, where you can edit the built-in roles or create your own.
</p>
<h3>Built-in roles</h3>
<table class="manual-table">
<thead><tr><th>Role</th><th>Typical use &amp; access</th></tr></thead>
<tbody>
<tr><td><strong>Administrator</strong></td><td>Full access (every capability), including users, roles, settings, and integrations. Always retains all capabilities.</td></tr>
<tr><td><strong>Finance</strong></td><td>Assets, books, reports, tax rules, maintenance, parts, contacts, assignments, alerts everything except system administration.</td></tr>
<tr><td><strong>Operations</strong></td><td>Day-to-day asset, maintenance, parts, contacts, scanning, assignments, and alert work. No financial or system configuration.</td></tr>
<tr><td><strong>PM Admin</strong></td><td>Owns preventative maintenance: create/edit PM plans &amp; defaults, complete services, manage parts, and work alerts.</td></tr>
<tr><td><strong>PM User</strong></td><td>Completes preventative maintenance and views assets, parts, and alerts but cant change PM plans.</td></tr>
<tr><td><strong>Viewer</strong></td><td>Read-only access to dashboards, assets, maintenance, and reports.</td></tr>
</tbody>
</table>
<h3>Custom roles (role editor)</h3>
<p>In <strong>Admin Users &amp; Teams</strong>, the <strong>Roles</strong> panel lets you tailor access:</p>
<ul>
<li><strong>New role</strong> name it, then tick the capabilities it should grant (grouped by area: Assets, Maintenance, Finance, etc.).</li>
<li><strong>Edit</strong> any role to change its name, description, or capabilities. Changes take effect the next time affected users sign in. The Administrator role cant be restricted.</li>
<li><strong>Delete</strong> a custom role once no users are assigned to it. Built-in roles cant be deleted.</li>
<li>Assign a role to a user in the users edit dialog; the role list there always reflects your current roles.</li>
</ul>
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
If you expect a screen or button that isnt there, your role likely doesnt include the capability for it an administrator
can grant it on the Roles panel or move you to a different role.
</v-alert>
</section>
<div class="manual-footer quiet">MixedAssets User Guide · select a topic on the left, or use Print / Save PDF for the full manual.</div>
</div>
</div>
</v-card>
</v-dialog>
</template>
<script>
export default {
props: {
modelValue: { type: Boolean, default: false }
},
emits: ['update:modelValue'],
data() {
return {
active: 'overview',
search: '',
printing: false,
sampleRuleSet: [
'{',
' "schema": "mixedassets.taxRuleSet.v1",',
' "jurisdiction": "US-CA",',
' "name": "California depreciation",',
' "version": "2026.1",',
' "effectiveDate": "2026-01-01",',
' "sourceNote": "Confirm against current state law before filing.",',
' "books": ["GAAP", "STATE"],',
' "conventions": ["half_year", "mid_quarter", "mid_month", "none"],',
' "limits": {',
' "section179": {',
' "deductionLimit": 25000,',
' "phaseOutBegins": 200000,',
' "cannotCreateLoss": true',
' },',
' "bonusDepreciation": {',
' "qualifiedPropertyPercent": 0',
' }',
' },',
' "assetLives": [',
' { "code": "5Y", "label": "5-year property", "months": 60 },',
' { "code": "7Y", "label": "7-year property", "months": 84 }',
' ],',
' "methods": [',
' {',
' "code": "straight_line",',
' "family": "GAAP",',
' "label": "Straight-line",',
' "formula": "straight_line",',
' "lifeMonths": 60,',
' "defaultConvention": "half_year"',
' },',
' {',
' "code": "state_200db_5",',
' "family": "MACRS",',
' "label": "200% declining balance, 5-year",',
' "formula": "macrs_declining_balance",',
' "lifeMonths": 60,',
' "rateMultiplier": 2,',
' "switchToStraightLine": true,',
' "defaultConvention": "half_year"',
' },',
' {',
' "code": "acrs_5yr_table",',
' "family": "ACRS",',
' "label": "ACRS 5-year (statutory rates)",',
' "formula": "rate_table",',
' "lifeMonths": 60,',
' "rates": [0.15, 0.22, 0.21, 0.21, 0.21]',
' }',
' ]',
'}'
].join('\n'),
sections: [
{ id: 'overview', title: 'Getting started', icon: 'mdi-rocket-launch-outline', keywords: 'intro begin login sign in welcome' },
{ id: 'navigation', title: 'Interface & navigation', icon: 'mdi-view-dashboard-outline', keywords: 'menu rail theme dark light table sort filter top bar' },
{ id: 'dashboard', title: 'Dashboard', icon: 'mdi-chart-box-outline', keywords: 'home summary chart category value' },
{ id: 'assets', title: 'Assets — creating & managing', icon: 'mdi-archive-search-outline', keywords: 'asset register create edit import export mass edit workbench files notes tasks warranty' },
{ id: 'lifecycle', title: 'Asset lifecycle', icon: 'mdi-cash-remove', keywords: 'dispose disposal sale retire gain loss lease amortization impairment adjustment write down' },
{ id: 'barcodes', title: 'Barcodes & scanning', icon: 'mdi-barcode-scan', keywords: 'barcode label print scan camera tag' },
{ id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports' },
{ id: 'taxrules', title: 'Tax rule sets', icon: 'mdi-scale-balance', keywords: 'tax rules jurisdiction method limits activate version' },
{ id: 'templates', title: 'Templates', icon: 'mdi-form-select', keywords: 'template defaults custom fields data entry' },
{ id: 'pm', title: 'Preventative maintenance', icon: 'mdi-wrench-clock', keywords: 'pm maintenance plan steps complete close signature ratings guidance photos schedule' },
{ id: 'parts', title: 'Parts inventory', icon: 'mdi-package-variant-closed', keywords: 'parts stock aisle bin reserve reorder supplier inventory' },
{ id: 'contacts', title: 'Contacts & CRM', icon: 'mdi-card-account-details-outline', keywords: 'contacts crm vendor employee contractor work location supplier notes' },
{ id: 'assignments', title: 'Assignments (custody)', icon: 'mdi-account-switch-outline', keywords: 'assignment custody employee release traceability' },
{ id: 'alerts', title: 'Alerts & reminders', icon: 'mdi-bell-alert-outline', keywords: 'alerts reminders severity scan acknowledge dismiss email digest webhook servicenow' },
{ id: 'servicenow', title: 'ServiceNow integration', icon: 'mdi-cloud-sync-outline', keywords: 'servicenow incident ticket cmdb import sync connection' },
{ id: 'admin', title: 'Administration', icon: 'mdi-cog-outline', keywords: 'admin users roles teams settings smtp email webhook workday configuration' },
{ id: 'roles', title: 'Roles & permissions', icon: 'mdi-shield-account-outline', keywords: 'roles permissions admin finance operations viewer access' }
]
};
},
computed: {
filteredSections() {
const needle = String(this.search || '').trim().toLowerCase();
if (!needle) return this.sections;
return this.sections.filter((s) =>
s.title.toLowerCase().includes(needle) || s.keywords.includes(needle) || s.keywords.split(' ').some((k) => k.startsWith(needle))
);
}
},
watch: {
modelValue(open) {
if (open) {
this.search = '';
this.printing = false;
}
}
},
methods: {
isShown(id) {
return this.printing || this.active === id;
},
select(id) {
this.active = id;
this.$nextTick(() => {
if (this.$refs.content) this.$refs.content.scrollTop = 0;
});
},
print() {
this.printing = true;
this.$nextTick(() => {
window.print();
this.printing = false;
});
}
}
};
</script>
<style scoped>
.manual-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.user-manual-nav {
flex: 0 0 264px;
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.12);
overflow-y: auto;
background: rgba(var(--v-theme-on-surface), 0.02);
}
.manual-content {
flex: 1 1 auto;
overflow-y: auto;
padding: 28px 36px;
}
.manual-section {
max-width: 820px;
}
.manual-section h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 14px;
padding-bottom: 8px;
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.4);
}
.manual-section h3 {
font-size: 1.08rem;
font-weight: 700;
margin: 22px 0 8px;
}
.manual-section p {
line-height: 1.6;
margin: 0 0 12px;
}
.manual-section ul,
.manual-section ol {
line-height: 1.6;
margin: 0 0 12px;
padding-left: 22px;
}
.manual-section li {
margin-bottom: 6px;
}
.manual-section a {
color: rgb(var(--v-theme-primary));
cursor: pointer;
}
.manual-callout {
margin: 14px 0 18px;
}
.manual-table {
width: 100%;
border-collapse: collapse;
margin: 6px 0 16px;
font-size: 0.92rem;
}
.manual-table th,
.manual-table td {
text-align: left;
vertical-align: top;
padding: 8px 12px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
}
.manual-table th {
font-weight: 700;
background: rgba(var(--v-theme-on-surface), 0.04);
}
.manual-section :deep(code),
.manual-section code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em;
padding: 1px 5px;
border-radius: 4px;
background: rgba(var(--v-theme-on-surface), 0.08);
}
.manual-code {
margin: 6px 0 16px;
padding: 14px 16px;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.06);
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
.manual-doc-title h1 {
font-size: 1.8rem;
font-weight: 800;
margin-bottom: 4px;
}
.manual-footer {
margin-top: 32px;
padding-top: 14px;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<v-card class="span-12 panel-pad">
<h2 class="section-title mb-1">Alert webhook</h2>
<p class="quiet text-body-2 mb-3">
Send every newly raised alert to an external HTTP endpoint as a JSON object (one POST per alert).
Delivery runs on the alert cycle alongside (or instead of) email. Leave the secret blank to keep the stored value.
</p>
<div class="form-grid">
<v-switch v-model="form.webhook_enabled" label="Enable webhook delivery" color="primary" density="compact" hide-details />
<div />
<v-text-field v-model="form.webhook_url" class="full" label="Webhook URL" placeholder="https://example.com/hooks/mixedassets" />
<v-text-field
v-model="form.webhook_secret"
type="password"
:label="settings.webhook_secret_set ? 'Signing secret (unchanged)' : 'Signing secret (optional)'"
hint="If set, each request is signed with HMAC-SHA256 in the X-MixedAssets-Signature header"
persistent-hint
autocomplete="new-password"
/>
<div class="d-flex align-center">
<v-btn
v-if="settings.webhook_secret_set"
variant="text"
size="small"
color="error"
prepend-icon="mdi-key-remove"
@click="clearSecret"
>Clear secret</v-btn>
</div>
</div>
<div class="toolbar-row mt-3">
<v-btn variant="tonal" prepend-icon="mdi-webhook" :loading="testing" :disabled="!form.webhook_url" @click="sendTest">Send test event</v-btn>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save webhook</v-btn>
</div>
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
<v-divider class="my-4" />
<div class="quiet text-caption mb-1">Example payload delivered per alert:</div>
<pre class="payload-sample">{{ samplePayload }}</pre>
</v-card>
</template>
<script>
import { apiRequest } from '../services/api';
export default {
props: {
token: { type: String, required: true }
},
data() {
return {
settings: {},
form: {
webhook_enabled: false,
webhook_url: '',
webhook_secret: ''
},
saving: false,
testing: false,
status: '',
statusType: 'success',
samplePayload: JSON.stringify({
event: 'alert',
id: 142,
type: 'pm_overdue',
severity: 'critical',
title: 'PM overdue: Lift quarterly service',
message: 'Lift quarterly service on FA-1042 was due 2026-05-20.',
due_date: '2026-05-20',
status: 'open',
reference_type: 'asset_pm',
reference_id: 88,
asset_id: 1042,
asset_code: 'FA-1042',
created_at: '2026-06-04T12:00:00.000Z',
sent_at: '2026-06-04T12:05:00.000Z'
}, null, 2)
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/notifications/settings')).json();
this.settings = data.settings;
this.form = {
webhook_enabled: data.settings.webhook_enabled,
webhook_url: data.settings.webhook_url,
webhook_secret: ''
};
},
async save() {
this.saving = true;
this.status = '';
try {
const payload = {
webhook_enabled: this.form.webhook_enabled,
webhook_url: this.form.webhook_url
};
if (this.form.webhook_secret) payload.webhook_secret = this.form.webhook_secret;
const data = await (await this.api('/api/notifications/settings', { method: 'PUT', body: JSON.stringify(payload) })).json();
this.settings = data.settings;
this.form.webhook_secret = '';
this.statusType = 'success';
this.status = 'Webhook settings saved.';
} catch (error) {
this.statusType = 'error';
this.status = error.message;
} finally {
this.saving = false;
}
},
async clearSecret() {
this.status = '';
try {
const data = await (await this.api('/api/notifications/settings', { method: 'PUT', body: JSON.stringify({ webhook_secret_clear: true }) })).json();
this.settings = data.settings;
this.form.webhook_secret = '';
this.statusType = 'success';
this.status = 'Signing secret cleared.';
} catch (error) {
this.statusType = 'error';
this.status = error.message;
}
},
async sendTest() {
this.testing = true;
this.status = '';
try {
// Persist the current URL/secret first so the test hits what the user typed.
await this.save();
const result = await (await this.api('/api/notifications/webhook-test', { method: 'POST', body: JSON.stringify({}) })).json();
this.statusType = 'success';
this.status = `Test event delivered (HTTP ${result.status}).`;
} catch (error) {
this.statusType = 'error';
this.status = `Test failed: ${error.message}`;
} finally {
this.testing = false;
}
}
}
};
</script>
<style scoped>
.payload-sample {
margin: 0;
padding: 12px 14px;
border-radius: 8px;
background: rgba(127, 127, 127, 0.1);
font-size: 0.78rem;
line-height: 1.45;
overflow-x: auto;
white-space: pre;
}
</style>

View File

@@ -19,6 +19,78 @@ export const taskTypeItems = ['maintenance', 'inspection', 'calibration', 'renew
export const conditionItems = ['excellent', 'good', 'fair', 'poor', 'out_of_service'];
export const disposalMethodItems = [
{ title: 'Sale', value: 'sale' },
{ title: 'Exchange', value: 'exchange' },
{ title: 'Involuntary conversion', value: 'involuntary_conversion' },
{ title: 'Like-kind exchange', value: 'like_kind' },
{ title: 'Abandonment', value: 'abandonment' }
];
export const propertyTypeItems = [
{ title: '§1245 tangible personal property', value: '1245' },
{ title: '§1250 real property', value: '1250' },
{ title: 'Other', value: 'other' }
];
export const adjustmentTypeItems = [
{ title: 'Impairment', value: 'impairment' },
{ title: 'Write-down', value: 'write_down' },
{ title: 'Write-up', value: 'write_up' },
{ title: 'Revaluation', value: 'revaluation' }
];
export const contactTypeItems = [
{ title: 'Employee', value: 'employee' },
{ title: 'Vendor', value: 'vendor' },
{ title: 'Service provider', value: 'service_provider' },
{ title: 'Contractor', value: 'contractor' },
{ title: 'Work location', value: 'work_location' },
{ title: 'Other', value: 'other' }
];
export const creditTermItems = [
{ title: 'N/A', value: 'na' },
{ title: 'Net 30 days', value: 'net_30' },
{ title: 'Net 60 days', value: 'net_60' },
{ title: 'Net 90 days', value: 'net_90' },
{ title: 'Net 180 days', value: 'net_180' }
];
export const pmFrequencyUnits = [
{ title: 'Days', value: 'days' },
{ title: 'Weeks', value: 'weeks' },
{ title: 'Months', value: 'months' },
{ title: 'Quarters', value: 'quarters' },
{ title: 'Bi-yearly (6 months)', value: 'semiannual' },
{ title: 'Years', value: 'years' }
];
export const partStatusItems = [
{ title: 'Active', value: 'active' },
{ title: 'Inactive', value: 'inactive' },
{ title: 'Discontinued', value: 'discontinued' }
];
export const unitOfMeasureItems = [
'each', 'box', 'case', 'pair', 'set', 'roll', 'foot', 'meter', 'liter', 'gallon', 'kg', 'lb'
];
export const partTransactionTypes = [
{ title: 'Receive (add stock)', value: 'receive' },
{ title: 'Issue (consume)', value: 'issue' },
{ title: 'Adjust (set on-hand count)', value: 'adjust' },
{ title: 'Reserve', value: 'reserve' },
{ title: 'Unreserve', value: 'unreserve' }
];
export const leaseFrequencyItems = [
{ title: 'Monthly', value: 'monthly' },
{ title: 'Quarterly', value: 'quarterly' },
{ title: 'Semiannual', value: 'semiannual' },
{ title: 'Annual', value: 'annual' }
];
export const customFieldTypes = [
{ title: 'Text', value: 'text' },
{ title: 'Number', value: 'numeric' },
@@ -27,15 +99,79 @@ export const customFieldTypes = [
];
export const themeItems = [
{ title: 'Dark', value: 'mixedAssetsDark' },
{ title: 'Light', value: 'mixedAssets' }
{ title: 'Dark', value: 'mixedAssetsDark', props: { subtitle: 'Default dark' } },
{ title: 'Light', value: 'mixedAssets', props: { subtitle: 'Default light' } },
{ title: 'Ocean', value: 'mixedAssetsOcean', props: { subtitle: 'Dark · teal' } },
{ title: 'Slate', value: 'mixedAssetsSlate', props: { subtitle: 'Dark · neutral' } },
{ title: 'High contrast', value: 'mixedAssetsContrast', props: { subtitle: 'Dark · accessible' } },
{ title: 'Sepia', value: 'mixedAssetsSepia', props: { subtitle: 'Light · warm' } }
];
export const darkThemes = ['mixedAssetsDark', 'mixedAssetsOcean', 'mixedAssetsSlate', 'mixedAssetsContrast'];
// Accessibility: user-adjustable base text size (scales the whole UI via the root font size).
export const fontSizeItems = [
{ title: 'Small', value: 'small', props: { subtitle: '88%' } },
{ title: 'Normal', value: 'normal', props: { subtitle: '100%' } },
{ title: 'Large', value: 'large', props: { subtitle: '113%' } },
{ title: 'Extra large', value: 'xlarge', props: { subtitle: '125%' } }
];
export const fontScaleMap = {
small: '14px',
normal: '16px',
large: '18px',
xlarge: '20px'
};
// Suggested accent colours; users may also enter any custom hex value.
export const accentPresets = [
'#2457a6', '#006c6a', '#7c3aed', '#c2185b',
'#b3261e', '#a56a00', '#2f7d4b', '#0277bd'
];
export const navItems = [
{ key: 'dashboard', label: 'Dashboard', icon: 'mdi-view-dashboard-outline' },
{ key: 'assets', label: 'Assets', icon: 'mdi-archive-search-outline' },
{ key: 'assignments', label: 'Assignments', icon: 'mdi-account-switch-outline' },
{
key: 'assets',
label: 'Assets',
icon: 'mdi-archive-search-outline',
children: [
{ key: 'scan', label: 'Scan', icon: 'mdi-barcode-scan' },
{ key: 'assignments', label: 'Assignments', icon: 'mdi-account-switch-outline' },
{ key: 'templates', label: 'Templates', icon: 'mdi-form-select' }
]
},
{ key: 'alerts', label: 'Alerts', icon: 'mdi-bell-alert-outline' },
{
key: 'pm',
label: 'Maintenance',
icon: 'mdi-wrench-clock',
children: [
{ key: 'parts', label: 'Parts inventory', icon: 'mdi-package-variant-closed' }
]
},
{ key: 'contacts', label: 'Contacts', icon: 'mdi-card-account-details-outline' },
{ key: 'reports', label: 'Reports', icon: 'mdi-chart-bar' },
{ key: 'templates', label: 'Templates', icon: 'mdi-form-select' },
{ key: 'admin', label: 'Admin', icon: 'mdi-cog-outline' }
{
key: 'financial',
label: 'Financial',
icon: 'mdi-finance',
group: true,
children: [
{ key: 'books', label: 'Books', icon: 'mdi-book-open-variant' },
{ key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' }
]
},
{
key: 'admin',
label: 'Admin',
icon: 'mdi-cog-outline',
group: true,
children: [
{ key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' },
{ key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' },
{ key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' }
]
}
];

View File

@@ -43,6 +43,62 @@ const vuetify = createVuetify({
warning: '#f0c06a',
error: '#ffb4ab'
}
},
mixedAssetsOcean: {
dark: true,
colors: {
background: '#0b1620',
surface: '#10212f',
primary: '#4fd1c5',
secondary: '#6aa9ff',
accent: '#f6ad55',
info: '#7cc4f0',
success: '#7ee0a0',
warning: '#f6c453',
error: '#ff8f87'
}
},
mixedAssetsSlate: {
dark: true,
colors: {
background: '#17191e',
surface: '#21242b',
primary: '#aab4ff',
secondary: '#8fd3c8',
accent: '#d9b38c',
info: '#a9c4dd',
success: '#8fd7a3',
warning: '#e6c06a',
error: '#f2a59c'
}
},
mixedAssetsContrast: {
dark: true,
colors: {
background: '#000000',
surface: '#0d0d0d',
primary: '#ffd400',
secondary: '#00e5ff',
accent: '#ff4081',
info: '#40c4ff',
success: '#00e676',
warning: '#ffea00',
error: '#ff5252'
}
},
mixedAssetsSepia: {
dark: false,
colors: {
background: '#f3ead6',
surface: '#fbf6ea',
primary: '#9a5b2f',
secondary: '#2f6f6a',
accent: '#b3791f',
info: '#3f6f99',
success: '#2f7d4b',
warning: '#a56a00',
error: '#b3261e'
}
}
}
},

View File

@@ -9,6 +9,11 @@ export async function apiRequest(path, token, options = {}) {
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
// An invalid/expired token (401) means the session is no longer valid — signal the
// app to drop back to the login screen. (403 is a role/permission denial, not a session issue.)
if (response.status === 401) {
window.dispatchEvent(new CustomEvent('mixedassets:unauthorized'));
}
throw new Error(body.error || `Request failed: ${response.status}`);
}
return response;

View File

@@ -9,6 +9,88 @@
box-sizing: border-box;
}
/* ---- Accessibility ------------------------------------------------------- */
/* Keyboard focus indicator (visible only for keyboard users, not mouse clicks). */
a:focus-visible,
button:focus-visible,
[tabindex]:focus-visible,
.v-btn:focus-visible,
.v-field:focus-within {
outline: 3px solid rgb(var(--v-theme-info, 60 111 153));
outline-offset: 2px;
}
/* Skip-to-content link: hidden until focused via Tab. */
.skip-link {
position: fixed;
top: 8px;
left: 8px;
z-index: 4000;
padding: 10px 16px;
border-radius: 8px;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
font-weight: 700;
text-decoration: none;
transform: translateY(-150%);
transition: transform 0.15s ease;
}
.skip-link:focus {
transform: translateY(0);
}
/* Screen-reader-only utility. */
.sr-only {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Respect users who prefer reduced motion. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
/* High-contrast boost: stronger borders, bolder text, underlined links. Opt-in per user. */
body.ma-contrast-boost .v-card,
body.ma-contrast-boost .v-field__outline,
body.ma-contrast-boost .v-table,
body.ma-contrast-boost .asset-table th,
body.ma-contrast-boost .asset-table td,
body.ma-contrast-boost .manual-table th,
body.ma-contrast-boost .manual-table td {
border-color: rgb(var(--v-theme-on-surface)) !important;
}
body.ma-contrast-boost .v-card {
border: 1px solid rgb(var(--v-theme-on-surface));
}
body.ma-contrast-boost .quiet,
body.ma-contrast-boost .text-caption,
body.ma-contrast-boost .v-list-item-subtitle {
opacity: 1 !important;
}
body.ma-contrast-boost a:not(.v-btn),
body.ma-contrast-boost .manual-section a {
text-decoration: underline;
}
body.ma-contrast-boost .v-btn {
outline: 1px solid rgba(var(--v-theme-on-surface), 0.4);
}
body {
margin: 0;
min-width: 320px;
@@ -20,7 +102,7 @@ body {
}
.rail {
border-right: 1px solid #dde3ec;
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.12);
}
.brand-lockup {
@@ -50,12 +132,12 @@ body {
}
.page-band {
border-bottom: 1px solid #dde3ec;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
padding: 22px 24px 18px;
}
.top-nav {
border-bottom: 1px solid rgba(120, 138, 163, 0.24);
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
padding: 0 18px;
}
@@ -117,7 +199,7 @@ body {
}
.metric-label {
color: #617085;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
@@ -131,7 +213,7 @@ body {
}
.metric-subtle {
color: #617085;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.86rem;
margin-top: 8px;
}
@@ -150,7 +232,7 @@ body {
}
.quiet {
color: #617085;
color: rgba(var(--v-theme-on-surface), 0.62);
}
.asset-table {
@@ -160,7 +242,7 @@ body {
.asset-table th,
.asset-table td {
border-bottom: 1px solid #e7ebf1;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
font-size: 0.88rem;
padding: 10px 12px;
text-align: left;
@@ -168,7 +250,7 @@ body {
}
.asset-table th {
color: #617085;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
@@ -176,7 +258,7 @@ body {
}
.asset-table tr:hover td {
background: #f8fafc;
background: rgba(var(--v-theme-on-surface), 0.05);
}
.status-chip {
@@ -188,27 +270,184 @@ body {
}
.status-in_service {
background: #e4f2eb;
color: #246140;
background: rgba(var(--v-theme-success), 0.16);
color: rgb(var(--v-theme-success));
}
.status-disposed {
background: #f7e3df;
color: #8d2b1d;
background: rgba(var(--v-theme-error), 0.16);
color: rgb(var(--v-theme-error));
}
.status-cip,
.status-reserved {
background: #f4ead9;
color: #7a4a00;
background: rgba(var(--v-theme-warning), 0.18);
color: rgb(var(--v-theme-warning));
}
.status-checked_out {
background: rgba(var(--v-theme-info), 0.16);
color: rgb(var(--v-theme-info));
}
.status-retired {
background: rgba(var(--v-theme-on-surface), 0.1);
color: rgba(var(--v-theme-on-surface), 0.7);
}
.panel-pad {
padding: 16px;
}
/* Reusable DataTable */
.table-scroll {
overflow: auto;
}
.asset-table.sticky-head thead th {
position: sticky;
top: 0;
z-index: 2;
background: rgb(var(--v-theme-surface));
}
.asset-table.sticky-head tfoot td {
position: sticky;
bottom: 0;
z-index: 2;
background: rgb(var(--v-theme-surface));
}
.asset-table th.sortable {
cursor: pointer;
user-select: none;
}
.asset-table th.sortable:hover {
color: rgb(var(--v-theme-primary));
}
.asset-table .th-label {
display: inline-flex;
align-items: center;
gap: 2px;
}
.asset-table .lead-col {
width: 44px;
}
.data-table-footer {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 10px;
}
/* PM completion photos */
.pm-photo-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pm-photo-wrap {
position: relative;
}
.pm-photo-thumb {
width: 84px;
height: 84px;
object-fit: cover;
border-radius: 8px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
}
.pm-photo-remove {
position: absolute;
top: -8px;
right: -8px;
}
/* Asset identification photos */
.asset-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.asset-photo-frame {
position: relative;
aspect-ratio: 4 / 3;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
}
.asset-photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-photo-remove {
position: absolute;
top: 6px;
right: 6px;
}
/* PM plan guidance photos */
.pm-guidance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
}
.pm-guidance-fig {
margin: 0;
}
.pm-guidance-fig img {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
}
/* User-guide print: render only the manual content as a clean document */
@media print {
body * {
visibility: hidden !important;
}
.user-manual-print,
.user-manual-print * {
visibility: visible !important;
}
.user-manual-print {
position: absolute !important;
inset: 0 !important;
height: auto !important;
max-height: none !important;
box-shadow: none !important;
}
.user-manual-nav,
.d-print-none {
display: none !important;
}
.user-manual-content {
overflow: visible !important;
height: auto !important;
max-height: none !important;
}
.manual-section {
max-width: none !important;
page-break-inside: avoid;
}
}
.chart-box {
position: relative;
height: 280px;
overflow: hidden;
}
.chart-box canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
}
.login-screen {
@@ -246,47 +485,14 @@ body {
margin-bottom: 10px;
}
.v-theme--mixedAssetsDark {
/* Dark themes get a dark color-scheme so native controls/scrollbars match. */
.v-theme--mixedAssetsDark,
.v-theme--mixedAssetsOcean,
.v-theme--mixedAssetsSlate,
.v-theme--mixedAssetsContrast {
color-scheme: dark;
}
.v-theme--mixedAssetsDark .rail {
border-right-color: rgba(143, 184, 255, 0.2);
}
.v-theme--mixedAssetsDark .page-band,
.v-theme--mixedAssetsDark .asset-table th,
.v-theme--mixedAssetsDark .asset-table td {
border-color: rgba(143, 184, 255, 0.16);
}
.v-theme--mixedAssetsDark .quiet,
.v-theme--mixedAssetsDark .metric-label,
.v-theme--mixedAssetsDark .metric-subtle,
.v-theme--mixedAssetsDark .asset-table th {
color: #a8b3c2;
}
.v-theme--mixedAssetsDark .asset-table tr:hover td {
background: rgba(143, 184, 255, 0.07);
}
.v-theme--mixedAssetsDark .status-in_service {
background: rgba(143, 215, 163, 0.16);
color: #9ee8b5;
}
.v-theme--mixedAssetsDark .status-disposed {
background: rgba(255, 180, 171, 0.16);
color: #ffb4ab;
}
.v-theme--mixedAssetsDark .status-cip,
.v-theme--mixedAssetsDark .status-reserved {
background: rgba(240, 192, 106, 0.18);
color: #ffd18a;
}
@media (max-width: 980px) {
.content-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
@@ -340,3 +546,64 @@ body {
min-width: 760px;
}
}
/* Barcode scanning + labels */
.scan-viewport {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
background: #000;
border-radius: 10px;
overflow: hidden;
}
.scan-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.scan-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
}
.label-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 14px;
}
.label-cell {
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 8px;
padding: 10px;
text-align: center;
}
.barcode-svg {
width: 100%;
height: 46px;
}
.barcode-id {
font-weight: 700;
font-size: 0.78rem;
letter-spacing: 0.04em;
margin-top: 4px;
}
.barcode-caption {
font-size: 0.7rem;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,7 +1,7 @@
export const BOOK_TYPES = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
export function makeDefaultBooks(asset = {}) {
return BOOK_TYPES.map((bookType) => ({
export function makeDefaultBooks(asset = {}, codes = BOOK_TYPES) {
return (codes && codes.length ? codes : BOOK_TYPES).map((bookType) => ({
book_type: bookType,
active: bookType === 'GAAP' || bookType === 'FEDERAL',
cost: null,
@@ -15,6 +15,20 @@ export function makeDefaultBooks(asset = {}) {
}));
}
// Merge an asset's saved books with the currently-enabled book codes: keep existing
// values, add defaults for newly-enabled books, and preserve any extra saved books.
export function mergeBooks(existing = [], codes = BOOK_TYPES, asset = {}) {
const byType = Object.fromEntries((existing || []).map((book) => [book.book_type, book]));
const defaults = makeDefaultBooks(asset, codes);
const merged = (codes && codes.length ? codes : BOOK_TYPES).map(
(code) => byType[code] || defaults.find((d) => d.book_type === code)
);
for (const book of existing || []) {
if (!merged.some((b) => b.book_type === book.book_type)) merged.push(book);
}
return merged;
}
export function blankAsset() {
const today = new Date().toISOString().slice(0, 10);
const base = {
@@ -44,6 +58,7 @@ export function blankAsset() {
barcode_value: '',
condition: '',
new_or_used: 'new',
special_zone: '',
listed_property: false,
business_use_percent: 100,
investment_use_percent: 0,

View File

@@ -1,5 +1,7 @@
<template>
<section class="content-grid">
<!-- USERS & TEAMS -->
<template v-if="adminSection === 'admin-users'">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
@@ -9,70 +11,154 @@
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="userDialog = true">Add user</v-btn>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Team</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="account in users" :key="account.id">
<td class="font-weight-bold">{{ account.name }}</td>
<td>{{ account.email }}</td>
<td>{{ account.team_name || 'No team' }}</td>
<td><v-chip size="small" variant="tonal">{{ account.role }}</v-chip></td>
<td><span :class="['status-chip', account.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ account.status }}</span></td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(account)" />
</td>
</tr>
</tbody>
</table>
</div>
<DataTable
:columns="userColumns"
:rows="users"
:page-size="10"
row-key="id"
has-actions
searchable
search-placeholder="Filter users"
empty-text="No users."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-team_name="{ row }">{{ row.team_name || 'No team' }}</template>
<template #cell-role="{ row }">
<v-chip size="small" variant="tonal">{{ roleName(row.role) }}</v-chip>
</template>
<template #cell-status="{ row }">
<span :class="['status-chip', row.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(row)" />
</template>
</DataTable>
</v-card>
<v-card class="span-5 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Teams</h2>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="teamDialog = true">Add team</v-btn>
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="openNewTeam">Add team</v-btn>
</div>
<v-list lines="two">
<v-list-item v-for="team in teams" :key="team.id" prepend-icon="mdi-account-group">
<v-list-item-title>{{ team.name }}</v-list-item-title>
<v-list-item-subtitle>{{ team.description || 'No description' }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-alert v-if="teamNotice" type="error" density="compact" class="mb-3">{{ teamNotice }}</v-alert>
<DataTable
:columns="teamColumns"
:rows="teams"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter teams"
empty-text="No teams."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-description="{ row }">{{ row.description || 'No description' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editTeam(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeTeam(row)" />
</template>
</DataTable>
</v-card>
<v-card class="span-7 panel-pad">
<h2 class="section-title mb-4">Role permissions</h2>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Capability</th>
<th v-for="role in roles" :key="role">{{ role }}</th>
</tr>
</thead>
<tbody>
<tr v-for="capability in roleCapabilities" :key="capability.key">
<td>{{ capability.label }}</td>
<td v-for="role in roles" :key="role">
<v-icon :color="capability.roles.includes(role) ? 'success' : 'disabled'" :icon="capability.roles.includes(role) ? 'mdi-check-circle' : 'mdi-minus-circle'" size="18" />
</td>
</tr>
</tbody>
</table>
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Roles &amp; permissions</h2>
<div class="quiet text-body-2">Built-in and custom roles, each granting a set of capabilities.</div>
</div>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-shield-plus-outline" @click="openNewRole">New role</v-btn>
</div>
<DataTable
:columns="roleColumns"
:rows="roles"
row-key="key"
:page-size="10"
has-actions
searchable
search-placeholder="Filter roles"
empty-text="No roles."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
<v-chip v-if="row.is_system" size="x-small" variant="tonal" class="ml-2">Built-in</v-chip>
</template>
<template #cell-capabilities="{ row }">{{ capabilitySummary(row) }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEditRole(row)" />
<v-btn v-if="!row.is_system" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDeleteRole(row)" />
</template>
</DataTable>
</v-card>
<!-- Role editor -->
<v-dialog v-model="roleDialog" max-width="640" scrollable>
<v-card>
<v-card-title>{{ roleForm.exists ? `Edit role · ${roleForm.name}` : 'New role' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="roleForm.name" label="Role name" :readonly="roleForm.locked" />
<v-text-field v-if="!roleForm.exists" v-model="roleForm.key" label="Key (optional)" placeholder="auto from name" />
</div>
<v-textarea v-model="roleForm.description" label="Description" rows="2" class="mt-2" />
<v-alert v-if="roleForm.locked" type="info" density="compact" variant="tonal" class="mt-2">
The Administrator role always has every capability and cant be restricted.
</v-alert>
<template v-else>
<v-divider class="my-3" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Capabilities</h3>
<v-spacer />
<span class="quiet text-caption">{{ roleForm.capabilities.length }} selected</span>
</div>
<div v-for="group in capabilityGroups" :key="group.name" class="cap-group">
<div class="cap-group-title">{{ group.name }}</div>
<v-checkbox
v-for="cap in group.items"
:key="cap.key"
v-model="roleForm.capabilities"
:value="cap.key"
:label="cap.label"
density="compact"
hide-details
/>
</div>
</template>
<v-alert v-if="roleError" type="error" density="compact" class="mt-3">{{ roleError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="roleDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!roleForm.name.trim()" @click="saveRole">Save role</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="roleDeleteDialog" max-width="460">
<v-card>
<v-card-title class="text-error">Delete role</v-card-title>
<v-card-text>
<p>Delete the role <strong>{{ roleDeleteTarget?.name }}</strong>?</p>
<v-alert v-if="roleDeleteTarget && roleDeleteTarget.user_count" type="warning" variant="tonal" density="comfortable" class="mt-2">
{{ roleDeleteTarget.user_count }} user(s) currently have this role and must be reassigned first.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="roleDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDeleteRole">Delete role</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<!-- APPLICATION CONFIGURATION -->
<template v-if="adminSection === 'admin-config'">
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Application settings</h2>
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
@@ -81,6 +167,20 @@
<v-text-field v-model="localSettings.database_path" label="Database path" readonly />
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-settings', localSettings)">Save settings</v-btn>
</v-card>
<AssetClassSettings :token="token" />
<DepreciationZoneSettings :token="token" />
<AssetIdTemplateSettings :token="token" />
<CategorySettings :token="token" @updated="$emit('categories-updated')" />
<DepartmentSettings :token="token" @updated="$emit('categories-updated')" />
<PmCategorySettings :token="token" />
<PartCategorySettings :token="token" />
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Tax rule sets</h2>
<v-list lines="two">
@@ -90,6 +190,18 @@
</v-list-item>
</v-list>
</v-card>
<PmSettings :token="token" />
</template>
<!-- INTEGRATIONS -->
<template v-if="adminSection === 'admin-integrations'">
<NotificationSettings :token="token" />
<WebhookSettings :token="token" />
<ServiceNowSettings :token="token" />
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
@@ -108,6 +220,8 @@
<v-text-field v-model="localWorkday.client_secret" type="password" label="Client secret" />
<v-text-field v-model="localWorkday.workers_path" label="Workers path" />
<v-switch v-model="localWorkday.enabled" label="Enabled" color="primary" density="compact" hide-details />
<v-switch v-model="localWorkday.sync_enabled" label="Automatic scheduled sync" color="primary" density="compact" hide-details />
<v-text-field v-model.number="localWorkday.sync_interval_hours" type="number" label="Sync interval (hours)" />
</div>
<div class="toolbar-row mt-3">
<div class="quiet text-body-2">Last sync: {{ workdayConnection.last_sync_at ? shortDate(workdayConnection.last_sync_at) : 'Never' }}</div>
@@ -116,6 +230,7 @@
</div>
<v-alert v-if="workdayConnection.last_sync_status" class="mt-3" density="compact" type="info">{{ workdayConnection.last_sync_status }}</v-alert>
</v-card>
</template>
<v-dialog v-model="userDialog" max-width="560">
<v-card>
@@ -125,7 +240,7 @@
<v-text-field v-model="userForm.name" label="Name" />
<v-text-field v-model="userForm.email" label="Email" />
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
<v-select v-model="userForm.role" :items="roles" label="Role" />
<v-select v-model="userForm.role" :items="roleSelectItems" item-title="title" item-value="value" label="Role" />
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
</div>
@@ -143,7 +258,7 @@
<v-dialog v-model="teamDialog" max-width="460">
<v-card>
<v-card-title>Add team</v-card-title>
<v-card-title>{{ teamForm.id ? 'Edit team' : 'Add team' }}</v-card-title>
<v-card-text>
<v-text-field v-model="teamForm.name" label="Team name" />
<v-textarea v-model="teamForm.description" label="Description" rows="2" />
@@ -159,36 +274,88 @@
</template>
<script>
import AssetClassSettings from '../components/AssetClassSettings.vue';
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
import CategorySettings from '../components/CategorySettings.vue';
import DataTable from '../components/DataTable.vue';
import DepartmentSettings from '../components/DepartmentSettings.vue';
import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue';
import NotificationSettings from '../components/NotificationSettings.vue';
import PartCategorySettings from '../components/PartCategorySettings.vue';
import PmCategorySettings from '../components/PmCategorySettings.vue';
import PmSettings from '../components/PmSettings.vue';
import ServiceNowSettings from '../components/ServiceNowSettings.vue';
import WebhookSettings from '../components/WebhookSettings.vue';
export default {
components: { AssetClassSettings, AssetIdTemplateSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PmCategorySettings, PmSettings, ServiceNowSettings, WebhookSettings },
props: {
roleCapabilities: { type: Array, default: () => [] },
rolePermissions: { type: Object, default: () => ({}) },
token: { type: String, default: '' },
section: { type: String, default: 'admin-users' },
capabilityCatalog: { type: Array, default: () => [] },
roles: { type: Array, default: () => [] },
settings: { type: Object, required: true },
shortDate: { type: Function, default: (value) => value || '' },
taxRules: { type: Array, required: true },
teamSaving: { type: Boolean, default: false },
teams: { type: Array, default: () => [] },
teamNotice: { type: String, default: '' },
userSaving: { type: Boolean, default: false },
users: { type: Array, default: () => [] },
workdayConnection: { type: Object, default: () => ({}) },
workdaySaving: { type: Boolean, default: false },
workdaySyncing: { type: Boolean, default: false }
},
emits: ['create-team', 'create-user', 'save-settings', 'save-workday', 'sync-workday', 'update-user'],
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
data() {
return {
localSettings: { ...this.settings },
localWorkday: { ...this.workdayConnection, client_secret: '' },
teamDialog: false,
teamForm: { name: '', description: '' },
teamForm: { id: null, name: '', description: '' },
userDialog: false,
userForm: this.blankUser()
userForm: this.blankUser(),
roleDialog: false,
roleForm: this.blankRole(),
roleError: '',
roleDeleteDialog: false,
roleDeleteTarget: null,
userColumns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'team_name', label: 'Team' },
{ key: 'role', label: 'Role' },
{ key: 'status', label: 'Status' }
],
teamColumns: [
{ key: 'name', label: 'Team' },
{ key: 'description', label: 'Description' }
],
roleColumns: [
{ key: 'name', label: 'Role' },
{ key: 'capabilities', label: 'Capabilities', sortable: false },
{ key: 'user_count', label: 'Users', format: 'number' }
]
};
},
computed: {
teamOptions() {
return this.teams.map((team) => ({ title: team.name, value: team.id }));
},
roleSelectItems() {
return this.roles.map((role) => ({ title: role.name, value: role.key }));
},
capabilityGroups() {
const groups = [];
for (const cap of this.capabilityCatalog) {
let group = groups.find((g) => g.name === cap.group);
if (!group) { group = { name: cap.group, items: [] }; groups.push(group); }
group.items.push(cap);
}
return groups;
},
adminSection() {
return ['admin-users', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
}
},
watch: {
@@ -229,13 +396,73 @@ export default {
};
this.userDialog = true;
},
roleSummary(role) {
return (this.rolePermissions[role] || []).join(' · ');
roleName(roleKey) {
return (this.roles.find((r) => r.key === roleKey) || {}).name || roleKey;
},
roleSummary(roleKey) {
const role = this.roles.find((r) => r.key === roleKey);
if (!role) return '';
return role.description || this.capabilitySummary(role);
},
capabilitySummary(role) {
if ((role.capabilities || []).includes('*')) return 'All capabilities';
return `${(role.capabilities || []).length} capabilit${(role.capabilities || []).length === 1 ? 'y' : 'ies'}`;
},
blankRole() {
return { exists: false, locked: false, key: '', name: '', description: '', capabilities: [] };
},
openNewRole() {
this.roleForm = this.blankRole();
this.roleError = '';
this.roleDialog = true;
},
openEditRole(role) {
this.roleForm = {
exists: true,
locked: Boolean(role.locked),
key: role.key,
name: role.name,
description: role.description || '',
capabilities: [...(role.capabilities || [])].filter((c) => c !== '*')
};
this.roleError = '';
this.roleDialog = true;
},
saveRole() {
if (!this.roleForm.name.trim()) return;
const payload = {
key: this.roleForm.key,
name: this.roleForm.name.trim(),
description: this.roleForm.description,
capabilities: this.roleForm.capabilities
};
this.$emit(this.roleForm.exists ? 'update-role' : 'create-role', payload);
this.roleDialog = false;
},
confirmDeleteRole(role) {
this.roleDeleteTarget = role;
this.roleDeleteDialog = true;
},
performDeleteRole() {
if (this.roleDeleteTarget) this.$emit('delete-role', this.roleDeleteTarget.key);
this.roleDeleteDialog = false;
this.roleDeleteTarget = null;
},
openNewTeam() {
this.teamForm = { id: null, name: '', description: '' };
this.teamDialog = true;
},
editTeam(team) {
this.teamForm = { id: team.id, name: team.name, description: team.description || '' };
this.teamDialog = true;
},
removeTeam(team) {
if (window.confirm(`Delete team "${team.name}"?`)) this.$emit('delete-team', team.id);
},
saveTeam() {
this.$emit('create-team', { ...this.teamForm });
this.$emit(this.teamForm.id ? 'update-team' : 'create-team', { ...this.teamForm });
this.teamDialog = false;
this.teamForm = { name: '', description: '' };
this.teamForm = { id: null, name: '', description: '' };
},
saveUser() {
const payload = { ...this.userForm };
@@ -247,3 +474,17 @@ export default {
}
};
</script>
<style scoped>
.cap-group {
margin-bottom: 12px;
}
.cap-group-title {
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.7;
margin: 6px 0 2px;
}
</style>

219
src/views/AlertsView.vue Normal file
View File

@@ -0,0 +1,219 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Open</div>
<div class="metric-value">{{ summary.open || 0 }}</div>
<div class="metric-subtle">Needing attention</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Critical</div>
<div class="metric-value">{{ summary.critical || 0 }}</div>
<div class="metric-subtle">Overdue or expired</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Acknowledged</div>
<div class="metric-value">{{ summary.acknowledged || 0 }}</div>
<div class="metric-subtle">In progress</div>
</v-card>
<v-card class="metric span-3 d-flex flex-column justify-center">
<v-btn color="primary" prepend-icon="mdi-refresh" :loading="loading" @click="refresh">Run scan</v-btn>
<v-btn v-if="isAdmin" class="mt-2" variant="tonal" size="small" prepend-icon="mdi-email-fast-outline" :loading="emailing" @click="emailDigest">Email digest now</v-btn>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<h2 class="section-title">Reminders &amp; alerts</h2>
<v-spacer />
<v-select v-model="statusFilter" :items="statusItems" label="Status" density="compact" hide-details style="max-width:180px" />
<v-select v-model="typeFilter" :items="typeItems" label="Type" density="compact" hide-details clearable style="max-width:220px" />
</div>
<DataTable
:columns="columns"
:rows="filteredAlerts"
row-key="id"
has-actions
searchable
search-placeholder="Filter alerts"
empty-text="No alerts you're all caught up."
>
<template #cell-severity="{ row }">
<v-chip :color="severityColor(row.severity)" size="small" variant="tonal">{{ row.severity }}</v-chip>
</template>
<template #cell-type="{ row }">{{ humanize(row.type) }}</template>
<template #cell-asset_code="{ row }">{{ row.asset_code || '—' }}</template>
<template #cell-status="{ row }">
<span class="text-capitalize">{{ row.status }}</span>
</template>
<template #cell-external_ticket_number="{ row }">
<a v-if="row.external_ticket_number" :href="row.external_ticket_url" target="_blank" rel="noopener" class="ticket-link">
<v-icon icon="mdi-ticket-confirmation-outline" size="14" /> {{ row.external_ticket_number }}
</a>
<span v-else class="quiet"></span>
</template>
<template #actions="{ row }">
<v-btn v-if="row.status === 'open'" size="small" variant="text" @click="setStatus(row, 'acknowledge')">Ack</v-btn>
<v-btn
v-if="!row.external_ticket_number && row.status !== 'dismissed'"
size="small"
variant="text"
color="primary"
:loading="ticketingId === row.id"
@click="submitTicket(row)"
>ServiceNow</v-btn>
<v-btn v-if="row.status !== 'dismissed'" size="small" variant="text" color="error" @click="setStatus(row, 'dismiss')">Dismiss</v-btn>
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
</v-card>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest } from '../services/api';
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
isAdmin: { type: Boolean, default: false }
},
data() {
return {
alerts: [],
summary: {},
statusFilter: 'open',
typeFilter: null,
loading: false,
emailing: false,
error: '',
message: '',
statusItems: [
{ title: 'Open', value: 'open' },
{ title: 'Acknowledged', value: 'acknowledged' },
{ title: 'Dismissed', value: 'dismissed' },
{ title: 'Resolved', value: 'resolved' },
{ title: 'All', value: 'all' }
],
typeItems: [
{ title: 'Maintenance overdue', value: 'maintenance_overdue' },
{ title: 'Maintenance due', value: 'maintenance_due' },
{ title: 'Warranty expired', value: 'warranty_expired' },
{ title: 'Warranty expiring', value: 'warranty_expiring' },
{ title: 'Lease ending', value: 'lease_expiring' }
],
columns: [
{ key: 'severity', label: 'Severity' },
{ key: 'type', label: 'Type' },
{ key: 'title', label: 'Alert' },
{ key: 'message', label: 'Detail' },
{ key: 'due_date', label: 'Due', format: 'date' },
{ key: 'asset_code', label: 'Asset' },
{ key: 'status', label: 'Status' },
{ key: 'external_ticket_number', label: 'Ticket', sortable: false }
],
ticketingId: null
};
},
computed: {
filteredAlerts() {
return this.alerts.filter((a) =>
(this.statusFilter === 'all' || a.status === this.statusFilter) &&
(!this.typeFilter || a.type === this.typeFilter)
);
}
},
mounted() {
this.refresh();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async refresh() {
this.loading = true;
this.error = '';
this.message = '';
try {
const data = await (await this.api('/api/alerts/scan', { method: 'POST' })).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (error) {
// Fall back to a read-only list if the user cannot scan.
try {
const data = await (await this.api('/api/alerts')).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (inner) {
this.error = inner.message;
}
} finally {
this.loading = false;
}
},
async setStatus(row, action) {
try {
await this.api(`/api/alerts/${row.id}/${action}`, { method: 'PUT' });
const data = await (await this.api('/api/alerts')).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (error) {
this.error = error.message;
}
},
async emailDigest() {
this.emailing = true;
this.error = '';
this.message = '';
try {
const result = await (await this.api('/api/alerts/run', { method: 'POST' })).json();
this.message = result.emailed
? `Digest emailed (${result.count} alert(s)).`
: 'Scan complete. No email sent (alerts disabled, SMTP not set, or nothing new).';
} catch (error) {
this.error = error.message;
} finally {
this.emailing = false;
}
},
async submitTicket(row) {
this.ticketingId = row.id;
this.error = '';
this.message = '';
try {
const { ticket } = await (await this.api(`/api/alerts/${row.id}/ticket`, { method: 'POST', body: JSON.stringify({}) })).json();
this.message = ticket.duplicate
? `Alert already linked to ${ticket.number}.`
: `Created ServiceNow incident ${ticket.number}.`;
const data = await (await this.api('/api/alerts')).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (error) {
this.error = `ServiceNow ticket failed: ${error.message}`;
} finally {
this.ticketingId = null;
}
},
severityColor(severity) {
return { critical: 'error', warning: 'warning', info: 'info' }[severity] || 'info';
},
humanize(value) {
return String(value || '').replace(/_/g, ' ');
}
}
};
</script>
<style scoped>
.ticket-link {
color: rgb(var(--v-theme-primary));
text-decoration: none;
white-space: nowrap;
}
.ticket-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -6,6 +6,9 @@
<v-select v-model="localFilters.status" :items="statusItems" clearable label="Status" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-pencil-multiple" :disabled="!selectedAssetIds.length" @click="$emit('open-mass-edit')">Mass edit</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-barcode" @click="$emit('print-labels')">
{{ selectedAssetIds.length ? `Labels (${selectedAssetIds.length})` : 'Labels' }}
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-upload" @click="$refs.importFile.click()">Import</v-btn>
<input ref="importFile" hidden type="file" accept=".csv,.json,.xlsx,.xls,.xml" @change="$emit('import-assets', $event)" />
<v-menu>
@@ -17,44 +20,39 @@
</v-list>
</v-menu>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th style="width:44px"><v-checkbox-btn :model-value="allSelected" @update:model-value="$emit('toggle-all', $event)" /></th>
<th>Asset ID</th>
<th>Description</th>
<th>Entity</th>
<th>Category</th>
<th>Status</th>
<th>Cost</th>
<th>Service date</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="asset in assets" :key="asset.id">
<td><v-checkbox-btn :model-value="selectedAssetIds.includes(asset.id)" @update:model-value="$emit('toggle-asset', asset.id)" /></td>
<td class="font-weight-bold">{{ asset.asset_id }}</td>
<td>{{ asset.description }}</td>
<td>{{ asset.entity_name }}</td>
<td>{{ asset.category }}</td>
<td><span :class="['status-chip', `status-${asset.status}`]">{{ asset.status }}</span></td>
<td>{{ currency(asset.acquisition_cost) }}</td>
<td>{{ asset.in_service_date }}</td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="$emit('edit-asset', asset)" />
</td>
</tr>
</tbody>
</table>
</div>
<DataTable
:columns="columns"
:rows="assets"
row-key="id"
has-lead
has-actions
empty-text="No assets match the current filters."
>
<template #lead-header>
<v-checkbox-btn :model-value="allSelected" @update:model-value="$emit('toggle-all', $event)" />
</template>
<template #lead="{ row }">
<v-checkbox-btn :model-value="selectedAssetIds.includes(row.id)" @update:model-value="$emit('toggle-asset', row.id)" />
</template>
<template #cell-asset_id="{ row }">
<span class="font-weight-bold">{{ row.asset_id }}</span>
</template>
<template #cell-status="{ row }">
<span :class="['status-chip', `status-${row.status}`]">{{ row.status }}</span>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="$emit('edit-asset', row)" />
</template>
</DataTable>
</v-card>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
export default {
components: { DataTable },
props: {
allSelected: { type: Boolean, default: false },
assets: { type: Array, required: true },
@@ -63,10 +61,19 @@ export default {
selectedAssetIds: { type: Array, required: true },
statusItems: { type: Array, required: true }
},
emits: ['edit-asset', 'export-assets', 'filter-assets', 'import-assets', 'open-mass-edit', 'toggle-all', 'toggle-asset'],
emits: ['edit-asset', 'export-assets', 'filter-assets', 'import-assets', 'open-mass-edit', 'print-labels', 'toggle-all', 'toggle-asset'],
data() {
return {
localFilters: { ...this.assetFilters }
localFilters: { ...this.assetFilters },
columns: [
{ key: 'asset_id', label: 'Asset ID' },
{ key: 'description', label: 'Description' },
{ key: 'entity_name', label: 'Entity' },
{ key: 'category', label: 'Category' },
{ key: 'status', label: 'Status' },
{ key: 'acquisition_cost', label: 'Cost', format: 'currency' },
{ key: 'in_service_date', label: 'Service date', format: 'date' }
]
};
},
watch: {

View File

@@ -37,15 +37,22 @@
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="employeeDialog = true">Add employee</v-btn>
</div>
<v-list lines="two">
<v-list-item v-for="employee in employees" :key="employee.id" prepend-icon="mdi-account">
<v-list-item-title>{{ employee.name }}</v-list-item-title>
<v-list-item-subtitle>{{ employee.email || employee.employee_number }} · {{ employee.department || 'No department' }} · {{ employee.location || 'No location' }}</v-list-item-subtitle>
<template #append>
<v-chip size="small" variant="tonal">{{ employee.source }}</v-chip>
</template>
</v-list-item>
</v-list>
<DataTable
:columns="employeeColumns"
:rows="employees"
row-key="id"
:page-size="10"
searchable
search-placeholder="Filter employees"
empty-text="No employees yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-source="{ row }">
<v-chip size="small" variant="tonal">{{ row.source }}</v-chip>
</template>
</DataTable>
</v-card>
<v-card class="span-12 panel-pad">
@@ -54,37 +61,29 @@
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-refresh" @click="$emit('reload')">Refresh</v-btn>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Asset</th>
<th>Employee</th>
<th>Department</th>
<th>Location</th>
<th>Assigned</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="assignment in assignments" :key="assignment.id">
<td>
<strong>{{ assignment.asset_code }}</strong><br />
<span class="quiet">{{ assignment.asset_description }}</span>
</td>
<td>{{ assignment.employee_name || 'Unassigned employee' }}</td>
<td>{{ assignment.department || '-' }}</td>
<td>{{ assignment.location || '-' }}</td>
<td>{{ shortDate(assignment.assigned_at) }}</td>
<td><span :class="['status-chip', assignment.active ? 'status-in_service' : 'status-disposed']">{{ assignment.status }}</span></td>
<td class="text-right">
<v-btn v-if="assignment.active" size="small" variant="text" color="warning" @click="$emit('release-assignment', assignment)">Release</v-btn>
</td>
</tr>
</tbody>
</table>
</div>
<DataTable
:columns="assignmentColumns"
:rows="assignments"
row-key="id"
has-actions
searchable
search-placeholder="Filter assignments"
empty-text="No assignment history yet."
>
<template #cell-asset_code="{ row }">
<strong>{{ row.asset_code }}</strong><br />
<span class="quiet">{{ row.asset_description }}</span>
</template>
<template #cell-employee_name="{ row }">{{ row.employee_name || 'Unassigned employee' }}</template>
<template #cell-department="{ row }">{{ row.department || '-' }}</template>
<template #cell-location="{ row }">{{ row.location || '-' }}</template>
<template #cell-status="{ row }">
<span :class="['status-chip', row.active ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
</template>
<template #actions="{ row }">
<v-btn v-if="row.active" size="small" variant="text" color="warning" @click="$emit('release-assignment', row)">Release</v-btn>
</template>
</DataTable>
</v-card>
<v-dialog v-model="employeeDialog" max-width="480">
@@ -108,7 +107,10 @@
</template>
<script>
import DataTable from '../components/DataTable.vue';
export default {
components: { DataTable },
props: {
assignments: { type: Array, required: true },
assets: { type: Array, required: true },
@@ -120,6 +122,22 @@ export default {
emits: ['add-employee', 'assign-asset', 'release-assignment', 'reload'],
data() {
return {
assignmentColumns: [
{ key: 'asset_code', label: 'Asset' },
{ key: 'employee_name', label: 'Employee' },
{ key: 'department', label: 'Department' },
{ key: 'location', label: 'Location' },
{ key: 'assigned_at', label: 'Assigned', format: 'date' },
{ key: 'status', label: 'Status' }
],
employeeColumns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'employee_number', label: 'Emp #' },
{ key: 'department', label: 'Department' },
{ key: 'location', label: 'Location' },
{ key: 'source', label: 'Source' }
],
assignmentForm: {
asset_id: null,
employee_id: null,

357
src/views/BooksView.vue Normal file
View File

@@ -0,0 +1,357 @@
<template>
<section class="content-grid">
<!-- Book configuration -->
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-1">
<h2 class="section-title">Books</h2>
<v-spacer />
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openNewBook">New book</v-btn>
</div>
<p class="quiet text-body-2 mb-3">Configure each set of books, assign the depreciation rule set it should use, and set entry defaults. Add user-defined books for additional reporting scenarios.</p>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Book</th>
<th>Type</th>
<th>Tax rule set</th>
<th>Default method</th>
<th>Convention</th>
<th>Assets</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="book in books" :key="book.id">
<td>
<div class="font-weight-bold">{{ book.code }}<v-chip v-if="book.is_primary" size="x-small" color="primary" variant="tonal" class="ml-2">primary</v-chip></div>
<v-text-field v-model="book.name" density="compact" hide-details variant="plain" />
</td>
<td style="min-width:140px">
<v-select v-model="book.book_type" :items="bookTypeItems" item-title="title" item-value="value" density="compact" hide-details />
</td>
<td style="min-width:220px">
<v-select
v-model="book.tax_rule_set_id"
:items="ruleSetItems"
item-title="title"
item-value="value"
density="compact"
hide-details
clearable
placeholder="Active set"
/>
</td>
<td style="min-width:220px">
<v-select v-model="book.default_method" :items="methodItems" item-title="title" item-value="value" density="compact" hide-details />
</td>
<td style="min-width:160px">
<v-select v-model="book.default_convention" :items="conventionItems" item-title="title" item-value="value" density="compact" hide-details />
</td>
<td>{{ book.asset_count }}</td>
<td><v-switch v-model="book.enabled" color="primary" density="compact" hide-details /></td>
<td class="text-right" style="white-space:nowrap">
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-content-save" :loading="savingId === book.id" @click="save(book)">Save</v-btn>
<v-btn
v-if="!book.is_primary && book.book_type !== 'maintenance'"
icon="mdi-delete-outline"
size="small"
variant="text"
color="error"
class="ml-1"
@click="removeBook(book)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
</v-card>
<!-- General ledger -->
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<h2 class="section-title">General ledger</h2>
<v-spacer />
<v-select v-model="ledgerBook" :items="bookCodeItems" label="Book" density="compact" hide-details style="max-width:180px" @update:model-value="loadLedger" />
<v-text-field v-model.number="ledgerYear" type="number" label="Year" density="compact" hide-details style="max-width:120px" @update:model-value="loadLedger" />
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportLedger(format)" />
</v-list>
</v-menu>
</div>
<div v-if="ledger">
<div v-if="ledger.kind === 'maintenance'" class="ledger-summary mb-4">
<div class="ledger-chip"><span class="quiet">Assets serviced</span><strong>{{ ledger.summary.assets }}</strong></div>
<div class="ledger-chip"><span class="quiet">Services ({{ ledger.year }})</span><strong>{{ ledger.summary.completions }}</strong></div>
<div class="ledger-chip"><span class="quiet">Total PM spend</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
</div>
<div v-else class="ledger-summary mb-4">
<div class="ledger-chip"><span class="quiet">Assets</span><strong>{{ ledger.summary.assets }}</strong></div>
<div class="ledger-chip"><span class="quiet">Cost</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Accumulated depr.</span><strong>{{ currency(ledger.summary.accumulated) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Net book value</span><strong>{{ currency(ledger.summary.nbv) }}</strong></div>
<div class="ledger-chip"><span class="quiet">{{ ledger.year }} depreciation</span><strong>{{ currency(ledger.summary.depreciation) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Rule set</span><strong>{{ ledger.rule_set.name || 'Active set' }}</strong></div>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr><th>GL account</th><th>Account type</th><th class="text-right">Debit</th><th class="text-right">Credit</th></tr>
</thead>
<tbody>
<tr v-for="(line, index) in ledger.accounts" :key="index">
<td class="font-weight-bold">{{ line.account }}</td>
<td>{{ line.type }}</td>
<td class="text-right">{{ line.debit ? currency(line.debit) : '' }}</td>
<td class="text-right">{{ line.credit ? currency(line.credit) : '' }}</td>
</tr>
<tr v-if="!ledger.accounts.length"><td colspan="4" class="quiet">No postings for this book and year.</td></tr>
</tbody>
<tfoot>
<tr>
<td class="font-weight-bold" colspan="2">Totals</td>
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.debit) }}</td>
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.credit) }}</td>
</tr>
</tfoot>
</table>
</div>
<v-expansion-panels class="mt-4" variant="accordion">
<v-expansion-panel title="Asset detail">
<v-expansion-panel-text>
<DataTable
:columns="detailColumns"
:rows="ledger.assets"
row-key="asset_id"
searchable
search-placeholder="Filter assets"
empty-text="No assets in this book."
>
<template #cell-asset_id="{ row }">
<span class="font-weight-bold">{{ row.asset_id }}</span>
</template>
</DataTable>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
</v-card>
<v-dialog v-model="newBookDialog" max-width="560">
<v-card>
<v-card-title>New book</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="newBook.code" label="Code" hint="Short unique code, e.g. IFRS" persistent-hint />
<v-text-field v-model="newBook.name" label="Name" />
<v-select v-model="newBook.book_type" :items="bookTypeItems" item-title="title" item-value="value" label="Type" />
<v-select v-model="newBook.tax_rule_set_id" :items="ruleSetItems" item-title="title" item-value="value" clearable placeholder="Active set" label="Tax rule set" />
<v-select v-model="newBook.default_method" :items="methodItems" item-title="title" item-value="value" label="Default method" />
<v-select v-model="newBook.default_convention" :items="conventionItems" item-title="title" item-value="value" label="Default convention" />
<v-textarea v-model="newBook.description" class="full" label="Description" rows="2" />
<v-switch v-model="newBook.enabled" label="Enabled (apply to assets)" color="primary" density="compact" hide-details />
</div>
<v-alert v-if="newBookError" type="error" density="compact" class="mt-2">{{ newBookError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="newBookDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!newBook.code" :loading="creating" @click="createBook">Create book</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest, saveBlob } from '../services/api';
import { currency } from '../utils/format';
import { conventionItems } from '../constants';
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
methodItems: { type: Array, default: () => [] }
},
emits: ['updated'],
data() {
return {
conventionItems,
bookTypeItems: [
{ title: 'Financial', value: 'financial' },
{ title: 'Tax', value: 'tax' },
{ title: 'Internal', value: 'internal' }
],
books: [],
ruleSets: [],
ledger: null,
ledgerBook: 'GAAP',
ledgerYear: new Date().getFullYear(),
savingId: null,
error: '',
message: '',
newBookDialog: false,
newBookError: '',
creating: false,
newBook: this.blankBook()
};
},
computed: {
ruleSetItems() {
return this.ruleSets.map((set) => ({ title: `${set.name} · ${set.version}`, value: set.id }));
},
bookCodeItems() {
return this.books.map((book) => book.code);
},
detailColumns() {
const year = this.ledger?.year || this.ledgerYear;
if (this.ledger?.kind === 'maintenance') {
return [
{ key: 'asset_id', label: 'Asset ID' },
{ key: 'description', label: 'Description' },
{ key: 'completions', label: 'Services', format: 'number' },
{ key: 'last_service', label: 'Last service', format: 'date' },
{ key: 'cost', label: `${year} PM cost`, format: 'currency' }
];
}
return [
{ key: 'asset_id', label: 'Asset ID' },
{ key: 'description', label: 'Description' },
{ key: 'asset_account', label: 'Asset acct' },
{ key: 'expense_account', label: 'Expense acct' },
{ key: 'cost', label: 'Cost', format: 'currency' },
{ key: 'depreciation', label: `${year} depr.`, format: 'currency' },
{ key: 'accumulated', label: 'Accum.', format: 'currency' },
{ key: 'nbv', label: 'NBV', format: 'currency' }
];
}
},
mounted() {
this.load();
},
methods: {
currency,
blankBook() {
return { code: '', name: '', description: '', book_type: 'internal', tax_rule_set_id: null, default_method: 'straight_line', default_convention: 'half_year', enabled: true };
},
openNewBook() {
this.newBook = this.blankBook();
this.newBookError = '';
this.newBookDialog = true;
},
async createBook() {
this.creating = true;
this.newBookError = '';
try {
await this.api('/api/books', { method: 'POST', body: JSON.stringify(this.newBook) });
this.newBookDialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.newBookError = error.message;
} finally {
this.creating = false;
}
},
async removeBook(book) {
this.error = '';
if (!window.confirm(`Delete book "${book.code}"?`)) return;
try {
await this.api(`/api/books/${book.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
},
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/books')).json();
this.books = data.books;
this.ruleSets = data.ruleSets;
if (this.books.length) {
if (!this.books.some((book) => book.code === this.ledgerBook)) this.ledgerBook = this.books[0].code;
this.loadLedger();
}
},
async save(book) {
this.savingId = book.id;
this.error = '';
this.message = '';
try {
await this.api(`/api/books/${book.id}`, {
method: 'PUT',
body: JSON.stringify({
name: book.name,
book_type: book.book_type,
enabled: book.enabled,
tax_rule_set_id: book.tax_rule_set_id,
default_method: book.default_method,
default_convention: book.default_convention
})
});
this.message = `${book.code} saved.`;
this.$emit('updated');
if (book.code === this.ledgerBook) this.loadLedger();
} catch (error) {
this.error = error.message;
} finally {
this.savingId = null;
}
},
async loadLedger() {
if (!this.ledgerBook) return;
this.error = '';
try {
const data = await (await this.api(`/api/books/${this.ledgerBook}/ledger?year=${this.ledgerYear}`)).json();
this.ledger = data.ledger;
} catch (error) {
this.error = error.message;
}
},
async exportLedger(format) {
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, {
method: 'POST',
body: JSON.stringify({ year: this.ledgerYear, format })
})).blob();
saveBlob(blob, `mixedassets-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
}
}
};
</script>
<style scoped>
.ledger-summary {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.ledger-chip {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 140px;
padding: 10px 14px;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 8px;
}
.ledger-chip strong {
font-size: 1.05rem;
}
</style>

253
src/views/ContactsView.vue Normal file
View File

@@ -0,0 +1,253 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Contacts</h2>
<div class="quiet text-body-2">Employees, vendors, service providers, contractors, work locations, and more.</div>
</div>
<v-spacer />
<v-select v-model="typeFilter" :items="typeFilterItems" item-title="title" item-value="value" label="Type" density="compact" hide-details style="max-width:200px" @update:model-value="load" />
<v-btn color="primary" prepend-icon="mdi-account-plus" @click="openNew">New contact</v-btn>
</div>
<DataTable
:columns="columns"
:rows="contacts"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter contacts"
empty-text="No contacts yet."
>
<template #cell-display_name="{ row }">
<span class="font-weight-bold">{{ row.display_name }}</span>
<v-chip v-if="row.source === 'workday'" size="x-small" variant="tonal" class="ml-2">Workday</v-chip>
</template>
<template #cell-type="{ row }">{{ typeLabel(row.type) }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-dialog v-model="dialog" max-width="760" scrollable>
<v-card>
<v-card-title>{{ form.id ? 'Edit contact' : 'New contact' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-select v-model="form.type" :items="contactTypeItems" item-title="title" item-value="value" label="Type" />
<v-select v-model="form.status" :items="['active', 'inactive']" label="Status" />
<template v-if="showName">
<v-text-field v-model="form.first_name" label="First name" />
<v-text-field v-model="form.last_name" label="Last name" />
</template>
<v-text-field v-if="showOrg" v-model="form.organization" class="full" :label="form.type === 'work_location' ? 'Location name' : 'Organization'" />
<v-text-field v-model="form.email" label="Email" />
<v-text-field v-model="form.phone" label="Phone (international)" placeholder="+1 555-0100" />
<!-- Employee / contractor job info -->
<template v-if="showJob">
<v-text-field v-if="form.type === 'employee'" v-model="form.employee_id" label="Employee ID" />
<v-text-field v-if="form.type === 'contractor'" v-model="form.tax_id" label="Tax ID / business license #" />
<v-text-field v-model="form.department" label="Department" />
<v-text-field v-model="form.work_location" label="Work location" />
<v-text-field v-model="form.start_date" type="date" label="Start date" />
<v-text-field v-model="form.manager_first_name" label="Manager first name" />
<v-text-field v-model="form.manager_last_name" label="Manager last name" />
<v-text-field v-model="form.manager_email" label="Manager email" />
</template>
<!-- Vendor / service provider -->
<template v-if="showVendor">
<div>
<div class="quiet text-caption mb-1">Rating</div>
<v-rating v-model="form.rating" length="5" color="amber" active-color="amber" half-increments="false" density="compact" clearable />
</div>
<v-select v-model="form.credit_term" :items="creditTermItems" item-title="title" item-value="value" label="Credit term" />
</template>
</div>
<v-divider class="my-4" />
<h3 class="section-title mb-2">Address</h3>
<div class="form-grid">
<v-text-field v-model="form.address_line1" class="full" label="Address line 1" />
<v-text-field v-model="form.address_line2" class="full" label="Address line 2" />
<v-text-field v-model="form.city" label="City / locality" />
<v-text-field v-model="form.state_region" label="State / region / province" />
<v-text-field v-model="form.postal_code" label="Postal code" />
<v-text-field v-model="form.country" label="Country" />
</div>
<v-textarea v-model="form.notes" class="mt-3" label="Notes" rows="2" />
<template v-if="form.id">
<v-divider class="my-4" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Notes log</h3>
</div>
<div class="toolbar-row">
<v-text-field v-model="noteDraft" label="Add a note" density="compact" hide-details @keyup.enter="addNote" />
<v-btn variant="tonal" prepend-icon="mdi-plus" :disabled="!noteDraft.trim()" @click="addNote">Add</v-btn>
</div>
<v-list v-if="form.contact_notes && form.contact_notes.length" density="compact">
<v-list-item v-for="note in form.contact_notes" :key="note.id">
<v-list-item-title style="white-space:normal">{{ note.body }}</v-list-item-title>
<v-list-item-subtitle>{{ note.author || 'Unknown' }} · {{ note.created_at }}</v-list-item-subtitle>
<template #append>
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="deleteNote(note.id)" />
</template>
</v-list-item>
</v-list>
</template>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-3">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save contact</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest } from '../services/api';
import { contactTypeItems, creditTermItems } from '../constants';
import { clonePlain } from '../utils/clone';
function blankContact() {
return {
id: null, type: 'employee', status: 'active', first_name: '', last_name: '', organization: '',
email: '', phone: '', address_line1: '', address_line2: '', city: '', state_region: '', postal_code: '', country: '',
notes: '', employee_id: '', tax_id: '', department: '', work_location: '',
manager_first_name: '', manager_last_name: '', manager_email: '', start_date: '', rating: null, credit_term: 'na',
contact_notes: []
};
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
canDelete: { type: Boolean, default: false }
},
data() {
return {
contactTypeItems,
creditTermItems,
contacts: [],
typeFilter: null,
dialog: false,
form: blankContact(),
noteDraft: '',
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'display_name', label: 'Name / organization' },
{ key: 'type', label: 'Type' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'department', label: 'Department' },
{ key: 'work_location', label: 'Location' }
]
};
},
computed: {
typeFilterItems() {
return [{ title: 'All types', value: null }, ...contactTypeItems];
},
showName() {
return ['employee', 'contractor', 'other'].includes(this.form.type);
},
showOrg() {
return ['vendor', 'service_provider', 'work_location', 'other'].includes(this.form.type);
},
showJob() {
return ['employee', 'contractor'].includes(this.form.type);
},
showVendor() {
return ['vendor', 'service_provider'].includes(this.form.type);
}
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
typeLabel(type) {
return (contactTypeItems.find((t) => t.value === type) || {}).title || type;
},
async load() {
this.error = '';
try {
const query = this.typeFilter ? `?type=${this.typeFilter}` : '';
const data = await (await this.api(`/api/contacts${query}`)).json();
this.contacts = data.contacts;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankContact();
this.dialogError = '';
this.noteDraft = '';
this.dialog = true;
},
async openEdit(contact) {
this.dialogError = '';
this.noteDraft = '';
const data = await (await this.api(`/api/contacts/${contact.id}`)).json();
this.form = { ...blankContact(), ...clonePlain(data.contact) };
this.dialog = true;
},
async save() {
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/contacts/${this.form.id}` : '/api/contacts';
const data = await (await this.api(url, { method, body: JSON.stringify(this.form) })).json();
this.form = { ...blankContact(), ...data.contact };
await this.load();
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
async remove(contact) {
if (!window.confirm(`Delete ${contact.display_name}?`)) return;
try {
await this.api(`/api/contacts/${contact.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
},
async addNote() {
const body = this.noteDraft.trim();
if (!body || !this.form.id) return;
await this.api(`/api/contacts/${this.form.id}/notes`, { method: 'POST', body: JSON.stringify({ body }) });
this.noteDraft = '';
const data = await (await this.api(`/api/contacts/${this.form.id}`)).json();
this.form.contact_notes = data.contact.contact_notes;
},
async deleteNote(noteId) {
await this.api(`/api/contacts/${this.form.id}/notes/${noteId}`, { method: 'DELETE' });
this.form.contact_notes = this.form.contact_notes.filter((n) => n.id !== noteId);
}
}
};
</script>

View File

@@ -28,6 +28,34 @@
<div class="chart-box">
<Bar :data="categoryChart" :options="chartOptions" />
</div>
<div class="mt-4" style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Category</th>
<th class="text-right">Assets</th>
<th class="text-right">Value</th>
</tr>
</thead>
<tbody>
<tr v-for="row in dashboard.byCategory" :key="row.category">
<td class="font-weight-bold">{{ row.category }}</td>
<td class="text-right">{{ row.count }}</td>
<td class="text-right">{{ currency(row.value) }}</td>
</tr>
<tr v-if="!dashboard.byCategory.length">
<td colspan="3" class="quiet">No assets yet.</td>
</tr>
</tbody>
<tfoot v-if="dashboard.byCategory.length">
<tr>
<td class="font-weight-bold">Total</td>
<td class="text-right font-weight-bold">{{ totalAssets }}</td>
<td class="text-right font-weight-bold">{{ currency(totalValue) }}</td>
</tr>
</tfoot>
</table>
</div>
</v-card>
<v-card class="span-5 panel-pad">
@@ -56,6 +84,14 @@ export default {
currency: { type: Function, required: true },
dashboard: { type: Object, required: true },
shortDate: { type: Function, required: true }
},
computed: {
totalAssets() {
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.count || 0), 0);
},
totalValue() {
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.value || 0), 0);
}
}
};
</script>

509
src/views/PartsView.vue Normal file
View File

@@ -0,0 +1,509 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Parts inventory</h2>
<div class="quiet text-body-2">Maintenance parts &amp; supplies stock levels, aisle/bin locations, and reservations. Separate from the depreciation register.</div>
</div>
<v-spacer />
<v-select
v-model="categoryFilter"
:items="categoryFilterItems"
label="Category"
density="compact"
hide-details
style="max-width:200px"
@update:model-value="load"
/>
<v-select
v-model="statusFilter"
:items="statusFilterItems"
item-title="title"
item-value="value"
label="Status"
density="compact"
hide-details
style="max-width:170px"
@update:model-value="load"
/>
<v-switch v-model="lowStockOnly" label="Low stock" color="warning" density="compact" hide-details inset @update:model-value="load" />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New part</v-btn>
</div>
<div class="kpi-row mb-3">
<div class="kpi"><span class="kpi-value">{{ parts.length }}</span><span class="kpi-label">Parts</span></div>
<div class="kpi"><span class="kpi-value">{{ totalOnHand }}</span><span class="kpi-label">Units on hand</span></div>
<div class="kpi"><span class="kpi-value">{{ totalReserved }}</span><span class="kpi-label">Reserved</span></div>
<div class="kpi"><span class="kpi-value">{{ currency(totalValue) }}</span><span class="kpi-label">Stock value</span></div>
<div class="kpi"><span class="kpi-value text-warning">{{ lowStockCount }}</span><span class="kpi-label">Below reorder</span></div>
</div>
<DataTable
:columns="columns"
:rows="parts"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter parts"
empty-text="No parts yet."
>
<template #cell-part_number="{ row }">
<span class="font-weight-bold">{{ row.part_number }}</span>
<v-chip v-if="row.low_stock" size="x-small" color="warning" variant="flat" class="ml-2">Low</v-chip>
<v-chip v-if="row.status !== 'active'" size="x-small" variant="tonal" class="ml-2">{{ row.status }}</v-chip>
</template>
<template #cell-available="{ row }">
<span :class="{ 'text-warning font-weight-bold': row.low_stock }">{{ row.available }}</span>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-dialog v-model="dialog" max-width="920" scrollable>
<v-card>
<v-card-title>{{ form.id ? `Edit part · ${form.part_number}` : 'New part' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.part_number" label="Part number *" />
<v-text-field v-model="form.name" label="Name *" />
<v-combobox v-model="form.category" :items="categoryOptions" label="Category" />
<v-select v-model="form.status" :items="partStatusItems" item-title="title" item-value="value" label="Status" />
<v-combobox v-model="form.unit_of_measure" :items="unitOfMeasureItems" label="Unit of measure" />
<v-text-field v-model.number="form.unit_cost" type="number" label="Unit cost" prefix="$" />
<v-text-field v-model.number="form.reorder_point" type="number" label="Reorder point" hint="Flag low stock at/below this" persistent-hint />
<v-text-field v-model.number="form.reorder_quantity" type="number" label="Reorder quantity" />
<v-text-field v-model="form.manufacturer" label="Manufacturer" />
<v-text-field v-model="form.manufacturer_part_number" label="Manufacturer part #" />
<v-text-field v-model="form.barcode" label="Barcode / UPC" />
<v-autocomplete
v-model="form.supplier_contact_id"
:items="supplierItems"
item-title="title"
item-value="value"
label="Supplier (from contacts)"
clearable
/>
</div>
<v-textarea v-model="form.measurements" class="mt-3" label="Measurements / specifications" rows="2" placeholder="e.g. 12mm x 50mm, M8 thread, 24V" />
<v-textarea v-model="form.description" class="mt-2" label="Description / identification notes" rows="2" />
<v-textarea v-model="form.notes" class="mt-2" label="Internal notes" rows="2" />
<template v-if="form.id">
<!-- ---- Stock locations ---- -->
<v-divider class="my-4" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Stock locations</h3>
<v-spacer />
<v-chip size="small" variant="tonal">On hand {{ form.on_hand }} · Reserved {{ form.reserved }} · Available {{ form.available }}</v-chip>
</div>
<v-table density="compact" class="mb-3">
<thead>
<tr>
<th>Location</th><th>Aisle</th><th>Bin</th>
<th class="text-right">On hand</th><th class="text-right">Reserved</th><th class="text-right">Available</th><th />
</tr>
</thead>
<tbody>
<tr v-for="s in form.stock" :key="s.id">
<td>{{ s.location_name }}</td>
<td>{{ s.aisle }}</td>
<td>{{ s.bin }}</td>
<td class="text-right">{{ s.quantity }}</td>
<td class="text-right">{{ s.reserved }}</td>
<td class="text-right">{{ s.available }}</td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="editStock(s)" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="removeStock(s)" />
</td>
</tr>
<tr v-if="!form.stock.length"><td colspan="7" class="quiet">No stock locations yet.</td></tr>
</tbody>
</v-table>
<div class="form-grid">
<v-select v-model="stockDraft.location_contact_id" :items="locationItems" item-title="title" item-value="value" label="Work location" clearable />
<v-text-field v-model="stockDraft.aisle" label="Aisle" />
<v-text-field v-model="stockDraft.bin" label="Bin" />
<v-text-field v-model.number="stockDraft.quantity" type="number" label="On hand" />
<v-text-field v-model.number="stockDraft.reserved" type="number" label="Reserved" />
<div class="d-flex align-center">
<v-btn variant="tonal" prepend-icon="mdi-content-save" @click="saveStock">{{ stockDraft.id ? 'Update location' : 'Add location' }}</v-btn>
<v-btn v-if="stockDraft.id" variant="text" class="ml-1" @click="resetStockDraft">Cancel</v-btn>
</div>
</div>
<!-- ---- Stock movements ---- -->
<v-divider class="my-4" />
<h3 class="section-title mb-2">Record movement</h3>
<div class="form-grid">
<v-select v-model="txDraft.stock_id" :items="stockLocationItems" item-title="title" item-value="value" label="Stock location" />
<v-select v-model="txDraft.type" :items="partTransactionTypes" item-title="title" item-value="value" label="Movement" />
<v-text-field v-model.number="txDraft.quantity" type="number" :label="txDraft.type === 'adjust' ? 'New on-hand count' : 'Quantity'" />
<v-autocomplete v-model="txDraft.asset_id" :items="assetItems" item-title="title" item-value="value" label="Asset (optional)" clearable />
<v-text-field v-model="txDraft.notes" class="full" label="Notes / reference" />
</div>
<div class="text-right mb-3">
<v-btn color="primary" variant="tonal" prepend-icon="mdi-swap-vertical" :disabled="!txDraft.stock_id" @click="recordTransaction">Apply movement</v-btn>
</div>
<v-table v-if="form.transactions && form.transactions.length" density="compact" class="movement-log">
<thead>
<tr><th>When</th><th>Movement</th><th class="text-right">Qty</th><th>Location</th><th>Asset</th><th>By</th><th>Notes</th></tr>
</thead>
<tbody>
<tr v-for="t in form.transactions" :key="t.id">
<td>{{ shortDate(t.created_at) }}</td>
<td>{{ txLabel(t.type) }}</td>
<td class="text-right">{{ t.quantity }}</td>
<td>{{ t.location_name }}<span v-if="t.aisle"> · {{ t.aisle }}/{{ t.bin }}</span></td>
<td>{{ t.asset_code }}</td>
<td>{{ t.created_by_name }}</td>
<td style="white-space:normal">{{ t.notes }}</td>
</tr>
</tbody>
</v-table>
<!-- ---- Photos ---- -->
<v-divider class="my-4" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Photos</h3>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.photoInput.click()">Add photo</v-btn>
<input ref="photoInput" type="file" accept="image/*" hidden @change="onPhoto" />
</div>
<div v-if="form.photos && form.photos.length" class="photo-grid">
<div v-for="p in form.photos" :key="p.id" class="photo-tile">
<img :src="p.data" :alt="p.name || 'part photo'" />
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="photo-remove" @click="removePhoto(p.id)" />
</div>
</div>
<div v-else class="quiet text-body-2">No photos yet.</div>
</template>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-3">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Close</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">{{ form.id ? 'Save changes' : 'Create part' }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest } from '../services/api';
import { currency, shortDate } from '../utils/format';
import { clonePlain } from '../utils/clone';
import { partStatusItems, partTransactionTypes, unitOfMeasureItems } from '../constants';
function blankPart() {
return {
id: null, part_number: '', name: '', description: '', category: '', manufacturer: '',
manufacturer_part_number: '', barcode: '', unit_of_measure: 'each', unit_cost: 0,
reorder_point: 0, reorder_quantity: 0, measurements: '', supplier_contact_id: null,
status: 'active', notes: '',
on_hand: 0, reserved: 0, available: 0, stock: [], photos: [], transactions: []
};
}
function blankStockDraft() {
return { id: null, location_contact_id: null, aisle: '', bin: '', quantity: 0, reserved: 0 };
}
function blankTxDraft() {
return { stock_id: null, type: 'receive', quantity: 1, asset_id: null, notes: '' };
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
canDelete: { type: Boolean, default: false }
},
data() {
return {
partStatusItems,
partTransactionTypes,
unitOfMeasureItems,
parts: [],
contacts: [],
assets: [],
managedCategories: [],
categoryFilter: null,
statusFilter: null,
lowStockOnly: false,
dialog: false,
form: blankPart(),
stockDraft: blankStockDraft(),
txDraft: blankTxDraft(),
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'part_number', label: 'Part #' },
{ key: 'name', label: 'Name' },
{ key: 'category', label: 'Category' },
{ key: 'supplier_name', label: 'Supplier' },
{ key: 'on_hand', label: 'On hand', format: 'number' },
{ key: 'reserved', label: 'Reserved', format: 'number' },
{ key: 'available', label: 'Available', format: 'number' },
{ key: 'location_count', label: 'Locations', format: 'number' }
]
};
},
computed: {
categoryOptions() {
// Admin-managed parts categories, plus any value already in use so existing parts stay selectable.
const inUse = this.parts.map((p) => p.category).filter(Boolean);
return [...new Set([...this.managedCategories, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
categoryFilterItems() {
return ['All categories', ...this.categoryOptions].map((c) => (c === 'All categories' ? { title: c, value: null } : c));
},
statusFilterItems() {
return [{ title: 'All statuses', value: null }, ...partStatusItems];
},
supplierItems() {
return this.contacts
.filter((c) => ['vendor', 'service_provider', 'contractor'].includes(c.type))
.map((c) => ({ title: c.display_name, value: c.id }));
},
locationItems() {
return this.contacts
.filter((c) => c.type === 'work_location')
.map((c) => ({ title: c.display_name, value: c.id }));
},
assetItems() {
return this.assets.map((a) => ({ title: `${a.asset_id} · ${a.description}`, value: a.id }));
},
stockLocationItems() {
return (this.form.stock || []).map((s) => ({
title: `${s.location_name}${s.aisle ? ` · ${s.aisle}/${s.bin || '—'}` : ''} (on hand ${s.quantity})`,
value: s.id
}));
},
totalOnHand() {
return this.parts.reduce((sum, p) => sum + Number(p.on_hand || 0), 0);
},
totalReserved() {
return this.parts.reduce((sum, p) => sum + Number(p.reserved || 0), 0);
},
totalValue() {
return this.parts.reduce((sum, p) => sum + Number(p.stock_value || 0), 0);
},
lowStockCount() {
return this.parts.filter((p) => p.low_stock).length;
}
},
mounted() {
this.load();
this.loadContacts();
this.loadAssets();
this.loadCategories();
},
methods: {
currency,
shortDate,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
txLabel(type) {
return (partTransactionTypes.find((t) => t.value === type) || {}).title || type;
},
async load() {
this.error = '';
try {
const params = new URLSearchParams();
if (this.categoryFilter) params.set('category', this.categoryFilter);
if (this.statusFilter) params.set('status', this.statusFilter);
if (this.lowStockOnly) params.set('low_stock', 'true');
const data = await (await this.api(`/api/parts?${params.toString()}`)).json();
this.parts = data.parts;
} catch (error) {
this.error = error.message;
}
},
async loadContacts() {
try {
const data = await (await this.api('/api/contacts')).json();
this.contacts = data.contacts;
} catch {
this.contacts = [];
}
},
async loadAssets() {
try {
const data = await (await this.api('/api/assets')).json();
this.assets = data.assets;
} catch {
this.assets = [];
}
},
async loadCategories() {
try {
const data = await (await this.api('/api/part-categories')).json();
this.managedCategories = (data.categories || []).map((category) => category.name).filter(Boolean);
} catch {
this.managedCategories = [];
}
},
openNew() {
this.form = blankPart();
this.resetStockDraft();
this.txDraft = blankTxDraft();
this.dialogError = '';
this.dialog = true;
},
async openEdit(part) {
this.dialogError = '';
this.resetStockDraft();
this.txDraft = blankTxDraft();
const data = await (await this.api(`/api/parts/${part.id}`)).json();
this.form = { ...blankPart(), ...clonePlain(data.part) };
this.dialog = true;
},
setForm(part) {
this.form = { ...blankPart(), ...clonePlain(part) };
},
async save() {
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/parts/${this.form.id}` : '/api/parts';
const data = await (await this.api(url, { method, body: JSON.stringify(this.form) })).json();
this.setForm(data.part);
await this.load();
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
async remove(part) {
if (!window.confirm(`Delete part ${part.part_number}?`)) return;
try {
await this.api(`/api/parts/${part.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
},
resetStockDraft() {
this.stockDraft = blankStockDraft();
},
editStock(s) {
this.stockDraft = { id: s.id, location_contact_id: s.location_contact_id, aisle: s.aisle, bin: s.bin, quantity: s.quantity, reserved: s.reserved };
},
async saveStock() {
this.dialogError = '';
try {
const data = await (await this.api(`/api/parts/${this.form.id}/stock`, { method: 'POST', body: JSON.stringify(this.stockDraft) })).json();
this.setForm(data.part);
this.resetStockDraft();
await this.load();
} catch (error) {
this.dialogError = error.message;
}
},
async removeStock(s) {
if (!window.confirm('Remove this stock location?')) return;
const data = await (await this.api(`/api/parts/${this.form.id}/stock/${s.id}`, { method: 'DELETE' })).json();
this.setForm(data.part);
await this.load();
},
async recordTransaction() {
this.dialogError = '';
try {
const data = await (await this.api(`/api/parts/${this.form.id}/transactions`, { method: 'POST', body: JSON.stringify(this.txDraft) })).json();
this.setForm(data.part);
this.txDraft = blankTxDraft();
await this.load();
} catch (error) {
this.dialogError = error.message;
}
},
onPhoto(event) {
const [file] = event.target.files || [];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
const data = await (await this.api(`/api/parts/${this.form.id}/photos`, {
method: 'POST',
body: JSON.stringify({ name: file.name, mime: file.type, data: reader.result })
})).json();
this.setForm(data.part);
} catch (error) {
this.dialogError = error.message;
}
};
reader.readAsDataURL(file);
event.target.value = '';
},
async removePhoto(id) {
const data = await (await this.api(`/api/parts/${this.form.id}/photos/${id}`, { method: 'DELETE' })).json();
this.setForm(data.part);
}
}
};
</script>
<style scoped>
.kpi-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.kpi {
display: flex;
flex-direction: column;
padding: 10px 16px;
border-radius: 10px;
background: rgba(127, 127, 127, 0.08);
min-width: 120px;
}
.kpi-value {
font-size: 1.25rem;
font-weight: 700;
}
.kpi-label {
font-size: 0.75rem;
opacity: 0.7;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.photo-tile {
position: relative;
width: 110px;
height: 110px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(127, 127, 127, 0.25);
}
.photo-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-remove {
position: absolute;
top: 4px;
right: 4px;
}
.movement-log {
max-height: 240px;
overflow-y: auto;
}
</style>

363
src/views/PmView.vue Normal file
View File

@@ -0,0 +1,363 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Preventative maintenance plans</h2>
<div class="quiet text-body-2">Reusable maintenance procedures with a frequency and step checklist. Attach plans to assets from the asset's PM tab.</div>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New plan</v-btn>
</div>
<DataTable
:columns="columns"
:rows="plans"
row-key="id"
has-actions
searchable
search-placeholder="Filter plans"
empty-text="No PM plans yet. Create one to get started."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-frequency="{ row }">every {{ row.frequency_value }} {{ unitLabel(row.frequency_unit) }}</template>
<template #cell-steps="{ row }">{{ row.steps.length }}</template>
<template #cell-estimated_minutes="{ row }">{{ row.estimated_minutes ? row.estimated_minutes + ' min' : '' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-card class="span-12 panel-pad">
<h2 class="section-title mb-1">Completed PM forms</h2>
<p class="quiet text-body-2 mb-3">Signed completion records with condition ratings, notes, and photos.</p>
<DataTable
:columns="completionColumns"
:rows="completions"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter completions"
empty-text="No completed PM forms yet."
>
<template #cell-completed_at="{ row }">{{ (row.completed_at || '').slice(0, 10) }}</template>
<template #cell-rating_safety="{ row }">{{ row.rating_safety ? row.rating_safety + '' : '' }}</template>
<template #cell-photo_count="{ row }">{{ row.photo_count }}</template>
<template #actions="{ row }">
<v-btn size="small" variant="text" prepend-icon="mdi-file-eye-outline" @click="viewCompletion(row.id)">View</v-btn>
</template>
</DataTable>
</v-card>
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
<v-dialog v-model="dialog" max-width="720" scrollable>
<v-card>
<v-card-title>{{ form.id ? 'Edit PM plan' : 'New PM plan' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.name" class="full" label="Plan name" />
<v-combobox v-model="form.category" :items="categoryItems" label="Category" clearable />
<v-text-field :model-value="totalStepMinutes" type="number" label="Estimated minutes (from steps)" readonly hint="Auto-totaled from step times" persistent-hint />
<v-text-field v-model.number="form.frequency_value" type="number" label="Every" />
<v-select v-model="form.frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Frequency unit" />
<v-textarea v-model="form.description" class="full" label="Description" rows="2" />
<v-textarea v-model="form.instructions" class="full" label="Safety / general instructions" rows="2" />
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Steps</h3>
<v-spacer />
<span class="quiet text-caption">Total {{ totalStepMinutes }} min</span>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addStep">Add step</v-btn>
</div>
<p v-if="!form.steps.length" class="quiet text-body-2">No steps yet — add the procedure steps (e.g. "Tighten lug bolt A3").</p>
<div v-for="(step, index) in form.steps" :key="index" class="pm-step">
<div class="pm-step-num">{{ index + 1 }}</div>
<div class="pm-step-fields">
<div class="d-flex" style="gap:8px">
<v-text-field v-model="step.title" density="compact" hide-details placeholder="Step title" style="flex:1 1 auto" />
<v-text-field v-model.number="step.est_minutes" type="number" density="compact" hide-details placeholder="Min" style="max-width:88px" suffix="min" />
</div>
<v-text-field v-model="step.details" density="compact" hide-details placeholder="Details (optional)" class="mt-1" />
</div>
<div class="pm-step-actions">
<v-btn icon="mdi-arrow-up" size="x-small" variant="text" :disabled="index === 0" @click="moveStep(index, -1)" />
<v-btn icon="mdi-arrow-down" size="x-small" variant="text" :disabled="index === form.steps.length - 1" @click="moveStep(index, 1)" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.steps.splice(index, 1)" />
</div>
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Components / parts</h3>
<v-spacer />
<span class="quiet text-caption">Total {{ currency(totalComponentCost) }}</span>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addComponent">Add component</v-btn>
</div>
<p v-if="!form.components.length" class="quiet text-body-2">No components — add parts (part #, description, supplier, cost) to track PM cost.</p>
<div v-for="(component, index) in form.components" :key="`c${index}`" class="pm-component">
<v-text-field v-model="component.part_number" density="compact" hide-details placeholder="Part #" style="max-width:120px" />
<v-text-field v-model="component.description" density="compact" hide-details placeholder="Description" style="flex:1 1 auto" />
<v-text-field v-model="component.supplier" density="compact" hide-details placeholder="Supplier" style="max-width:140px" />
<v-text-field v-model.number="component.cost" type="number" density="compact" hide-details placeholder="Cost" prefix="$" style="max-width:110px" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.components.splice(index, 1)" />
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Guidance photos</h3>
<v-spacer />
<v-btn size="small" variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.planPhotoInput.click()">Add photos</v-btn>
<input ref="planPhotoInput" hidden type="file" accept="image/*" capture="environment" multiple @change="onPhotoPick" />
</div>
<p v-if="!form.photos.length" class="quiet text-body-2">Add reference photos (diagrams, part locations, before/after) shown to technicians during the service.</p>
<div v-else class="pm-plan-photo-grid">
<div v-for="(photo, index) in form.photos" :key="photo.id || `np${index}`" class="pm-plan-photo">
<div class="pm-plan-photo-frame">
<img :src="photo.data" :alt="photo.caption || 'guidance'" />
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="pm-plan-photo-remove" @click="form.photos.splice(index, 1)" />
</div>
<v-text-field v-model="photo.caption" density="compact" hide-details placeholder="Caption" class="mt-1" />
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!form.name" :loading="saving" @click="save">Save plan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import PmCompletionDialog from '../components/PmCompletionDialog.vue';
import { apiRequest } from '../services/api';
import { pmFrequencyUnits } from '../constants';
import { currency } from '../utils/format';
import { clonePlain } from '../utils/clone';
function blankPlan() {
return { id: null, name: '', description: '', category: '', frequency_value: 3, frequency_unit: 'months', estimated_minutes: null, instructions: '', steps: [], components: [], photos: [] };
}
export default {
components: { DataTable, PmCompletionDialog },
props: {
token: { type: String, required: true }
},
data() {
return {
pmFrequencyUnits,
plans: [],
pmCategories: [],
completions: [],
completionDialog: false,
completionId: null,
dialog: false,
form: blankPlan(),
saving: false,
error: '',
columns: [
{ key: 'name', label: 'Plan' },
{ key: 'category', label: 'Category' },
{ key: 'frequency', label: 'Frequency', sortable: false },
{ key: 'steps', label: 'Steps' },
{ key: 'estimated_minutes', label: 'Est. time' },
{ key: 'components_total', label: 'Parts cost', format: 'currency' }
],
completionColumns: [
{ key: 'asset_code', label: 'Asset' },
{ key: 'plan_name', label: 'Plan' },
{ key: 'completed_at', label: 'Completed', format: 'date' },
{ key: 'completed_by_name', label: 'By' },
{ key: 'rating_safety', label: 'Safety' },
{ key: 'photo_count', label: 'Photos', format: 'number' }
]
};
},
computed: {
categoryItems() {
// Admin-managed PM categories, plus any value already in use so existing plans stay selectable.
const inUse = this.plans.map((plan) => plan.category).filter(Boolean);
return [...new Set([...this.pmCategories, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
totalStepMinutes() {
return this.form.steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
},
totalComponentCost() {
return this.form.components.reduce((sum, component) => sum + (Number(component.cost) || 0), 0);
}
},
mounted() {
this.load();
this.loadCompletions();
this.loadCategories();
},
methods: {
currency,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/pm-plans')).json();
this.plans = data.plans;
},
async loadCompletions() {
const data = await (await this.api('/api/pm-completions')).json();
this.completions = data.completions;
},
async loadCategories() {
try {
const data = await (await this.api('/api/pm-categories')).json();
this.pmCategories = (data.categories || []).map((category) => category.name).filter(Boolean);
} catch {
this.pmCategories = [];
}
},
viewCompletion(id) {
this.completionId = id;
this.completionDialog = true;
},
unitLabel(unit) {
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
},
openNew() {
this.form = blankPlan();
this.error = '';
this.dialog = true;
},
async openEdit(plan) {
this.error = '';
// Fetch the full plan so guidance-photo image data (omitted from the list) is available.
let full = plan;
try {
const data = await (await this.api(`/api/pm-plans/${plan.id}`)).json();
full = data.plan;
} catch {
// fall back to the list row (photos will lack image data)
}
this.form = {
...clonePlain(full),
steps: clonePlain(full.steps || []),
components: clonePlain(full.components || []),
photos: clonePlain(full.photos || [])
};
this.dialog = true;
},
addStep() {
this.form.steps.push({ title: '', details: '', est_minutes: null });
},
addComponent() {
this.form.components.push({ part_number: '', description: '', supplier: '', cost: null });
},
onPhotoPick(event) {
for (const file of Array.from(event.target.files || [])) {
const reader = new FileReader();
reader.onload = () => this.form.photos.push({ name: file.name, mime: file.type, data: String(reader.result), caption: '' });
reader.readAsDataURL(file);
}
event.target.value = '';
},
moveStep(index, dir) {
const target = index + dir;
const steps = this.form.steps;
[steps[index], steps[target]] = [steps[target], steps[index]];
},
async save() {
this.saving = true;
this.error = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/pm-plans/${this.form.id}` : '/api/pm-plans';
// Existing photos carry an id; resend only {id, caption} so the stored blob isn't re-uploaded.
const photos = (this.form.photos || []).map((p) => (p.id
? { id: p.id, caption: p.caption }
: { data: p.data, name: p.name, mime: p.mime, caption: p.caption }));
await this.api(url, { method, body: JSON.stringify({ ...this.form, photos }) });
this.dialog = false;
await this.load();
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
async remove(plan) {
this.error = '';
try {
await this.api(`/api/pm-plans/${plan.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.pm-step {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.pm-step-num {
flex: 0 0 26px;
height: 26px;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.15);
color: rgb(var(--v-theme-primary));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
margin-top: 4px;
}
.pm-step-fields {
flex: 1 1 auto;
}
.pm-step-actions {
display: flex;
align-items: center;
}
.pm-component {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.pm-plan-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.pm-plan-photo-frame {
position: relative;
aspect-ratio: 4 / 3;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
}
.pm-plan-photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pm-plan-photo-remove {
position: absolute;
top: 6px;
right: 6px;
}
</style>

View File

@@ -1,76 +1,287 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<v-select v-model="localFilters.book" :items="bookItems" label="Book" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
<v-text-field v-model.number="localFilters.year" type="number" label="Year" hide-details style="max-width: 140px" @update:model-value="emitFilters" />
<v-spacer />
<div class="font-weight-bold">Annual depreciation: {{ currency(depreciationReport.totals.depreciation) }}</div>
</div>
<div class="chart-box mb-4">
<Bar :data="monthlyChart" :options="chartOptions" />
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Asset ID</th>
<th>Description</th>
<th>Method</th>
<th>Cost</th>
<th>Depreciation</th>
<th>Accumulated</th>
<th>NBV</th>
</tr>
</thead>
<tbody>
<tr v-for="row in depreciationReport.rows" :key="row.asset_id">
<td class="font-weight-bold">{{ row.asset_id }}</td>
<td>{{ row.description }}</td>
<td>{{ row.methodLabel || row.method }}</td>
<td>{{ currency(row.cost) }}</td>
<td>{{ currency(row.depreciation) }}</td>
<td>{{ currency(row.accumulated) }}</td>
<td>{{ currency(row.net_book_value) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Report picker -->
<v-card class="span-4 panel-pad">
<h2 class="section-title mb-2">Reports</h2>
<v-list density="compact" nav>
<template v-for="group in groupedReports" :key="group.name">
<v-list-subheader>{{ group.name }}</v-list-subheader>
<v-list-item
v-for="report in group.reports"
:key="report.key"
:active="selectedType === report.key"
:title="report.title"
rounded="sm"
@click="selectReport(report.key)"
/>
</template>
</v-list>
<template v-if="savedReports.length">
<v-divider class="my-3" />
<h3 class="section-title mb-2">Saved reports</h3>
<v-list density="compact">
<v-list-item v-for="saved in savedReports" :key="saved.id" :title="saved.name" :subtitle="saved.report_type" @click="applySaved(saved)">
<template #append>
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click.stop="deleteSaved(saved.id)" />
</template>
</v-list-item>
</v-list>
</template>
</v-card>
<!-- Report output -->
<v-card class="span-8 panel-pad">
<div class="toolbar-row mb-3" style="flex-wrap:wrap;gap:10px">
<template v-for="key in currentParams" :key="key">
<v-select
v-if="paramSpec(key).type === 'select'"
v-model="options[key]"
:items="normalizeOptions(paramSpec(key).options)"
:label="paramSpec(key).label"
:clearable="paramSpec(key).clearable"
item-title="title"
item-value="value"
density="compact"
hide-details
style="max-width:200px"
@update:model-value="run"
/>
<v-select
v-else-if="paramSpec(key).type === 'multiselect'"
v-model="options[key]"
:items="normalizeOptions(paramSpec(key).options)"
:label="paramSpec(key).label"
item-title="title"
item-value="value"
density="compact"
hide-details
chips
multiple
closable-chips
style="min-width:280px"
@update:model-value="run"
/>
<v-text-field
v-else-if="paramSpec(key).type === 'date'"
v-model="options[key]"
:label="paramSpec(key).label"
type="date"
density="compact"
hide-details
style="max-width:180px"
@update:model-value="run"
/>
<v-text-field
v-else
v-model.number="options[key]"
:label="paramSpec(key).label"
type="number"
density="compact"
hide-details
style="max-width:130px"
@update:model-value="run"
/>
</template>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-content-save-outline" @click="openSave">Save</v-btn>
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportReport(format)" />
</v-list>
</v-menu>
</div>
<div v-if="report">
<div class="toolbar-row mb-2">
<h2 class="section-title">{{ report.title }}</h2>
<v-spacer />
<span class="quiet text-caption">{{ report.rows.length }} row(s)</span>
</div>
<v-alert v-if="report.meta && report.meta.note" type="info" density="compact" variant="tonal" class="mb-3">{{ report.meta.note }}</v-alert>
<div v-if="report.meta && report.meta.asset" class="form-grid mb-3">
<div v-for="(value, key) in report.meta.asset" :key="key">
<div class="quiet text-caption">{{ key.replace(/_/g, ' ') }}</div>
<div class="text-body-2 font-weight-medium">{{ value || '—' }}</div>
</div>
</div>
<div v-if="report.chart" class="chart-box mb-4">
<Bar :data="chartData" :options="chartOptions" />
</div>
<DataTable
v-if="report.columns.length"
:columns="report.columns"
:totals="hasTotals ? report.totals : null"
:rows="report.rows"
searchable
search-placeholder="Filter this report"
empty-text="No data for the selected options."
/>
</div>
<div v-else class="quiet text-body-2">Select a report to begin.</div>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-dialog v-model="saveDialog" max-width="440">
<v-card>
<v-card-title>Save report</v-card-title>
<v-card-text>
<v-text-field v-model="saveName" label="Report name" autofocus />
<div class="quiet text-caption">{{ report ? report.title : '' }} · current options will be saved.</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="saveDialog = false">Cancel</v-btn>
<v-btn color="primary" :disabled="!saveName" @click="saveReport">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
import DataTable from '../components/DataTable.vue';
import { apiRequest, saveBlob } from '../services/api';
export default {
components: { Bar },
components: { Bar, DataTable },
props: {
bookItems: { type: Array, required: true },
chartOptions: { type: Object, required: true },
currency: { type: Function, required: true },
depreciationReport: { type: Object, required: true },
monthlyChart: { type: Object, required: true },
reportFilters: { type: Object, required: true }
token: { type: String, required: true }
},
emits: ['filter-reports'],
data() {
return {
localFilters: { ...this.reportFilters }
reports: [],
params: {},
selectedType: null,
options: {},
report: null,
savedReports: [],
saveDialog: false,
saveName: '',
error: '',
chartOptions: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}` } } }
}
};
},
watch: {
reportFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
computed: {
groupedReports() {
const groups = {};
for (const report of this.reports) {
groups[report.group] = groups[report.group] || [];
groups[report.group].push(report);
}
return Object.entries(groups).map(([name, reports]) => ({ name, reports }));
},
currentParams() {
return this.reports.find((report) => report.key === this.selectedType)?.params || [];
},
hasTotals() {
return this.report && this.report.totals && Object.keys(this.report.totals).length > 0;
},
chartData() {
const chart = this.report?.chart;
if (!chart) return { labels: [], datasets: [] };
return {
labels: chart.labels,
datasets: [{ data: chart.values, backgroundColor: '#2457a6' }]
};
}
},
mounted() {
this.loadCatalog();
this.loadSavedReports();
},
methods: {
emitFilters() {
this.$emit('filter-reports', { ...this.localFilters });
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async loadCatalog() {
try {
const data = await (await this.api('/api/reports/catalog')).json();
this.reports = data.reports;
this.params = data.params;
if (this.reports.length) this.selectReport(this.reports[0].key);
} catch (error) {
this.error = error.message;
}
},
async loadSavedReports() {
const data = await (await this.api('/api/saved-reports')).json();
this.savedReports = data.reports;
},
paramSpec(key) {
return this.params[key] || { label: key, type: 'text' };
},
normalizeOptions(options) {
return (options || []).map((option) => (typeof option === 'object' ? option : { title: String(option), value: option }));
},
defaultOptions(type) {
const report = this.reports.find((item) => item.key === type);
const options = {};
for (const key of report?.params || []) {
const spec = this.paramSpec(key);
options[key] = Array.isArray(spec.default) ? [...spec.default] : spec.default;
}
return options;
},
selectReport(type) {
this.selectedType = type;
this.options = this.defaultOptions(type);
this.run();
},
applySaved(saved) {
this.selectedType = saved.report_type;
this.options = { ...this.defaultOptions(saved.report_type), ...(saved.options_json || {}) };
this.run();
},
async run() {
if (!this.selectedType) return;
this.error = '';
try {
const data = await (await this.api('/api/reports/run', {
method: 'POST',
body: JSON.stringify({ type: this.selectedType, options: this.options })
})).json();
this.report = data.report;
} catch (error) {
this.error = error.message;
}
},
async exportReport(format) {
const blob = await (await this.api('/api/reports/export', {
method: 'POST',
body: JSON.stringify({ type: this.selectedType, options: this.options, format })
})).blob();
saveBlob(blob, `mixedassets-${this.selectedType}.${format}`);
},
openSave() {
this.saveName = this.report?.title || 'Saved report';
this.saveDialog = true;
},
async saveReport() {
await this.api('/api/saved-reports', {
method: 'POST',
body: JSON.stringify({ name: this.saveName, report_type: this.selectedType, options: this.options })
});
this.saveDialog = false;
await this.loadSavedReports();
},
async deleteSaved(id) {
await this.api(`/api/saved-reports/${id}`, { method: 'DELETE' });
await this.loadSavedReports();
}
}
};

224
src/views/ScanView.vue Normal file
View File

@@ -0,0 +1,224 @@
<template>
<section class="content-grid">
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-1">Scan an asset</h2>
<p class="quiet text-body-2 mb-3">
Point your camera at an asset barcode, or type the code to look it up and update it in the field.
</p>
<div class="scan-viewport mb-3">
<video ref="video" class="scan-video" muted playsinline></video>
<div v-if="!scanning" class="scan-placeholder">
<v-icon icon="mdi-barcode-scan" size="40" />
<span>Camera off</span>
</div>
</div>
<div class="toolbar-row">
<v-btn v-if="!scanning" color="primary" prepend-icon="mdi-camera" :loading="starting" @click="startScan">Start camera</v-btn>
<v-btn v-else color="error" variant="tonal" prepend-icon="mdi-stop" @click="stopScan">Stop</v-btn>
<v-select
v-if="deviceItems.length > 1"
v-model="deviceId"
:items="deviceItems"
item-title="title"
item-value="value"
density="compact"
hide-details
label="Camera"
style="max-width: 240px"
@update:model-value="restartScan"
/>
</div>
<v-divider class="my-4" />
<div class="toolbar-row">
<v-text-field
v-model="manualCode"
label="Enter barcode / asset ID"
hide-details
prepend-inner-icon="mdi-keyboard-outline"
@keyup.enter="lookup(manualCode)"
/>
<v-btn variant="tonal" prepend-icon="mdi-magnify" @click="lookup(manualCode)">Look up</v-btn>
</div>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
</v-card>
<v-card v-if="asset" class="span-6 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">{{ asset.asset_id }}</h2>
<div class="quiet text-body-2">{{ asset.description }}</div>
</div>
<v-spacer />
<span :class="['status-chip', `status-${form.status}`]">{{ form.status }}</span>
</div>
<div class="form-grid">
<v-select v-model="form.status" :items="statusItems" label="Status" />
<v-select v-model="form.condition" :items="conditionItems" clearable label="Condition" />
<v-text-field v-model="form.location" label="Location" />
<v-text-field v-model="form.department" label="Department" />
<v-text-field v-model="form.custodian" label="Custodian" />
<v-textarea v-model="fieldNote" class="full" label="Add field note" rows="2" />
</div>
<div class="toolbar-row mt-3">
<v-spacer />
<v-btn variant="text" @click="clearAsset">Clear</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Update asset</v-btn>
</div>
</v-card>
<v-card v-else class="span-6 panel-pad d-flex align-center justify-center" style="min-height: 220px">
<div class="quiet text-body-2 text-center">
<v-icon icon="mdi-cube-scan" size="40" class="mb-2" /><br>
Scan or look up an asset to update it here.
</div>
</v-card>
</section>
</template>
<script>
import { apiRequest } from '../services/api';
import { conditionItems, statusItems } from '../constants';
export default {
props: {
token: { type: String, required: true }
},
emits: ['asset-updated'],
data() {
return {
conditionItems,
statusItems,
reader: null,
controls: null,
scanning: false,
starting: false,
devices: [],
deviceId: null,
asset: null,
form: { status: 'in_service', condition: null, location: '', department: '', custodian: '' },
fieldNote: '',
manualCode: '',
error: '',
message: '',
saving: false
};
},
computed: {
deviceItems() {
return this.devices.map((device, index) => ({
title: device.label || `Camera ${index + 1}`,
value: device.deviceId
}));
}
},
beforeUnmount() {
this.stopScan();
},
methods: {
async startScan() {
this.error = '';
this.message = '';
this.starting = true;
try {
const { BrowserMultiFormatReader } = await import('@zxing/browser');
this.reader = new BrowserMultiFormatReader();
this.devices = await BrowserMultiFormatReader.listVideoInputDevices();
if (!this.devices.length) throw new Error('No camera found on this device');
// Prefer a rear-facing camera when we can identify one.
if (!this.deviceId) {
const back = this.devices.find((device) => /back|rear|environment/i.test(device.label || ''));
this.deviceId = (back || this.devices[this.devices.length - 1]).deviceId;
}
this.scanning = true;
this.controls = await this.reader.decodeFromVideoDevice(this.deviceId, this.$refs.video, (result, _err, controls) => {
if (result) {
controls.stop();
this.scanning = false;
this.lookup(result.getText());
}
});
} catch (error) {
this.scanning = false;
this.error = `Camera unavailable: ${error.message}. Camera access needs HTTPS or localhost — you can still enter codes manually.`;
} finally {
this.starting = false;
}
},
stopScan() {
try {
this.controls?.stop();
} catch {
// controls may already be stopped
}
this.controls = null;
this.scanning = false;
},
async restartScan() {
if (this.scanning) {
this.stopScan();
await this.startScan();
}
},
async lookup(code) {
const value = String(code || '').trim();
if (!value) return;
this.error = '';
this.message = '';
try {
const response = await apiRequest(`/api/assets/lookup?code=${encodeURIComponent(value)}`, this.token);
const data = await response.json();
this.asset = data.asset;
this.manualCode = value;
this.form = {
status: data.asset.status,
condition: data.asset.condition || null,
location: data.asset.location || '',
department: data.asset.department || '',
custodian: data.asset.custodian || ''
};
this.fieldNote = '';
} catch (error) {
this.asset = null;
this.error = error.message;
}
},
async save() {
if (!this.asset) return;
this.saving = true;
this.error = '';
try {
await apiRequest(`/api/assets/${this.asset.id}`, this.token, {
method: 'PUT',
body: JSON.stringify(this.form)
});
if (this.fieldNote.trim()) {
await apiRequest(`/api/assets/${this.asset.id}/notes`, this.token, {
method: 'POST',
body: JSON.stringify({ body: this.fieldNote.trim() })
});
}
this.message = `${this.asset.asset_id} updated`;
this.$emit('asset-updated');
this.clearAsset();
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
clearAsset() {
this.asset = null;
this.fieldNote = '';
this.manualCode = '';
}
}
};
</script>

254
src/views/TaxRulesView.vue Normal file
View File

@@ -0,0 +1,254 @@
<template>
<section class="content-grid">
<!-- Rule set list -->
<v-card class="span-4 panel-pad">
<div class="toolbar-row mb-2">
<h2 class="section-title">Tax rule sets</h2>
<v-spacer />
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="newDraft">New</v-btn>
</div>
<p class="quiet text-body-2 mb-2">Template-driven JSON rule sets for federal, state, and local depreciation law updates.</p>
<v-btn block variant="tonal" prepend-icon="mdi-upload" class="mb-3" @click="$refs.uploadInput.click()">Upload JSON</v-btn>
<input ref="uploadInput" hidden type="file" accept=".json,application/json" @change="onUpload" />
<v-list density="compact" nav>
<v-list-item
v-for="ruleSet in ruleSets"
:key="ruleSet.id"
:active="selected && selected.id === ruleSet.id"
rounded="sm"
@click="select(ruleSet)"
>
<v-list-item-title>{{ ruleSet.name }}</v-list-item-title>
<v-list-item-subtitle>{{ ruleSet.jurisdiction }} · {{ ruleSet.version }} · {{ ruleSet.effective_date }}</v-list-item-subtitle>
<template #append>
<v-chip v-if="ruleSet.active" size="x-small" color="success" variant="tonal">active</v-chip>
</template>
</v-list-item>
</v-list>
</v-card>
<!-- Editor -->
<v-card class="span-8 panel-pad">
<div class="form-grid mb-3">
<v-text-field v-model="form.name" label="Name" />
<v-text-field v-model="form.jurisdiction" label="Jurisdiction" placeholder="US-FED, US-CA, ..." />
<v-text-field v-model="form.version" label="Version" />
<v-text-field v-model="form.effective_date" type="date" label="Effective date" />
<v-text-field v-model="form.source_note" class="full" label="Source note" />
<v-switch v-model="form.active" label="Active" color="primary" density="compact" hide-details />
</div>
<div class="toolbar-row mb-2" style="flex-wrap:wrap;gap:8px">
<v-btn size="small" variant="text" prepend-icon="mdi-code-braces" @click="formatJson">Format</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-table-refresh" :loading="expanding" @click="regenerateMethods">Regenerate method catalog</v-btn>
<v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-download" @click="exportJson">Export</v-btn>
<v-btn v-if="selected && !form.active" size="small" variant="tonal" prepend-icon="mdi-check-circle-outline" @click="activate">Activate</v-btn>
<v-btn v-if="selected" size="small" variant="text" color="error" prepend-icon="mdi-delete-outline" @click="remove">Delete</v-btn>
<v-btn color="primary" size="small" prepend-icon="mdi-content-save" :disabled="!jsonValid" :loading="saving" @click="save">Save</v-btn>
</div>
<v-alert v-if="jsonError" type="error" density="compact" class="mb-2">{{ jsonError }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mb-2">{{ message }}</v-alert>
<v-alert v-if="error" type="error" density="compact" class="mb-2">{{ error }}</v-alert>
<component :is="editorComponent" ref="editor" v-model="editorContent" :theme="monacoTheme" />
</v-card>
</section>
</template>
<script>
import { defineAsyncComponent } from 'vue';
import { apiRequest, saveBlob } from '../services/api';
function blankForm() {
return {
id: null,
name: '',
jurisdiction: 'US-FED',
version: '',
effective_date: new Date().toISOString().slice(0, 10),
source_note: '',
active: true
};
}
export default {
props: {
token: { type: String, required: true },
dark: { type: Boolean, default: true }
},
emits: ['updated'],
data() {
return {
editorComponent: defineAsyncComponent(() => import('../components/MonacoJsonEditor.vue')),
ruleSets: [],
selected: null,
form: blankForm(),
editorContent: '{\n "jurisdiction": "US-FED",\n "name": "New rule set",\n "version": "1.0",\n "effectiveDate": "2026-01-01",\n "limits": {}\n}',
saving: false,
expanding: false,
error: '',
message: ''
};
},
computed: {
monacoTheme() {
return this.dark ? 'vs-dark' : 'vs';
},
parsed() {
try {
return { value: JSON.parse(this.editorContent), error: '' };
} catch (error) {
return { value: null, error: error.message };
}
},
jsonValid() {
return this.parsed.error === '';
},
jsonError() {
return this.editorContent.trim() ? this.parsed.error : '';
}
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/tax-rules')).json();
this.ruleSets = data.ruleSets;
if (!this.selected && this.ruleSets.length) this.select(this.ruleSets[0]);
},
select(ruleSet) {
this.message = '';
this.error = '';
this.selected = ruleSet;
this.form = {
id: ruleSet.id,
name: ruleSet.name,
jurisdiction: ruleSet.jurisdiction,
version: ruleSet.version,
effective_date: ruleSet.effective_date,
source_note: ruleSet.source_note || '',
active: ruleSet.active
};
this.editorContent = JSON.stringify(ruleSet.rules_json, null, 2);
},
newDraft() {
this.selected = null;
this.form = blankForm();
this.editorContent = JSON.stringify({
jurisdiction: 'US-FED',
name: 'New rule set',
version: '1.0',
effectiveDate: new Date().toISOString().slice(0, 10),
limits: { section179: { deductionLimit: 0, phaseOutBegins: 0 }, bonusDepreciation: { qualifiedPropertyPercent: 0 } }
}, null, 2);
this.message = '';
this.error = '';
},
onUpload(event) {
const [file] = event.target.files || [];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
this.selected = null;
this.editorContent = String(reader.result || '');
try {
const parsed = JSON.parse(this.editorContent);
this.form = {
id: null,
name: parsed.name || file.name.replace(/\.json$/i, ''),
jurisdiction: parsed.jurisdiction || 'US-FED',
version: parsed.version || '1.0',
effective_date: parsed.effectiveDate || parsed.effective_date || new Date().toISOString().slice(0, 10),
source_note: parsed.sourceNote || '',
active: false
};
this.message = 'File loaded — review and Save to import.';
} catch {
this.message = '';
this.error = 'Uploaded file is not valid JSON; fix it in the editor before saving.';
}
};
reader.readAsText(file);
event.target.value = '';
},
formatJson() {
this.$refs.editor?.format?.();
},
async regenerateMethods() {
if (!this.jsonValid) return;
this.expanding = true;
this.error = '';
try {
const data = await (await this.api('/api/tax-rules/expand', {
method: 'POST',
body: JSON.stringify({ rules_json: this.parsed.value })
})).json();
this.editorContent = JSON.stringify(data.rules_json, null, 2);
this.message = 'Method catalog regenerated (68 methods).';
} catch (error) {
this.error = error.message;
} finally {
this.expanding = false;
}
},
async save() {
if (!this.jsonValid) return;
this.saving = true;
this.error = '';
this.message = '';
try {
const payload = { ...this.form, rules_json: this.parsed.value };
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/tax-rules/${this.form.id}` : '/api/tax-rules';
const data = await (await this.api(url, { method, body: JSON.stringify(payload) })).json();
this.message = 'Rule set saved.';
await this.load();
this.select(this.ruleSets.find((set) => set.id === data.ruleSet.id) || data.ruleSet);
this.$emit('updated');
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
async activate() {
if (!this.selected) return;
this.error = '';
try {
const data = await (await this.api(`/api/tax-rules/${this.selected.id}/activate`, { method: 'POST' })).json();
this.ruleSets = data.ruleSets;
this.select(this.ruleSets.find((set) => set.id === this.selected.id));
this.message = 'Rule set activated.';
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
},
async remove() {
if (!this.selected) return;
this.error = '';
try {
await this.api(`/api/tax-rules/${this.selected.id}`, { method: 'DELETE' });
this.selected = null;
this.form = blankForm();
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
},
exportJson() {
const name = `${this.form.jurisdiction || 'rules'}-${this.form.version || 'export'}.json`.replace(/\s+/g, '-');
saveBlob(new Blob([this.editorContent], { type: 'application/json' }), name);
}
}
};
</script>

View File

@@ -1,7 +1,11 @@
<template>
<section class="content-grid">
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Asset template</h2>
<div class="toolbar-row mb-4">
<h2 class="section-title">{{ localForm.id ? 'Edit template' : 'New asset template' }}</h2>
<v-spacer />
<v-btn v-if="localForm.id" variant="text" size="small" prepend-icon="mdi-plus" @click="startNew">New</v-btn>
</div>
<v-text-field v-model="localForm.name" label="Name" />
<v-textarea v-model="localForm.description" label="Description" rows="2" />
<v-select v-model="localForm.defaults.category" :items="categoryItems" label="Category" />
@@ -23,41 +27,115 @@
<v-switch v-model="field.required" label="Required" color="primary" density="compact" hide-details />
<v-btn icon="mdi-delete" variant="text" color="error" @click="localForm.custom_fields.splice(index, 1)" />
</div>
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-template', localForm)">Save template</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!localForm.name" @click="$emit('save-template', localForm)">
{{ localForm.id ? 'Update template' : 'Save template' }}
</v-btn>
</v-card>
<v-card class="span-7 panel-pad">
<h2 class="section-title mb-4">Templates</h2>
<v-list lines="two">
<v-list-item v-for="template in templates" :key="template.id" prepend-icon="mdi-form-select">
<v-list-item-title>{{ template.name }}</v-list-item-title>
<v-list-item-subtitle>{{ template.description }}</v-list-item-subtitle>
<template #append>
<v-chip size="small" variant="tonal">{{ template.custom_fields.length }} fields</v-chip>
</template>
</v-list-item>
</v-list>
<h2 class="section-title mb-3">Templates</h2>
<DataTable
:columns="columns"
:rows="templateRows"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter templates"
empty-text="No templates yet. Create one on the left."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-description="{ row }">{{ row.description || '—' }}</template>
<template #actions="{ row }">
<v-btn v-if="canDelete" icon="mdi-pencil" size="small" variant="text" @click="editTemplate(row)" />
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
</v-card>
<v-dialog v-model="deleteDialog" max-width="520">
<v-card>
<v-card-title class="text-error">Delete template</v-card-title>
<v-card-text>
<p class="mb-3">Delete the template <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert
:type="deleteTarget && deleteTarget.asset_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
class="mb-3"
>
<template v-if="deleteTarget && deleteTarget.asset_count">
This template is currently assigned to <strong>{{ deleteTarget.asset_count }}</strong>
asset{{ deleteTarget.asset_count === 1 ? '' : 's' }}.
</template>
<template v-else>
This template is not assigned to any assets.
</template>
</v-alert>
<p class="mb-2">When you delete it:</p>
<ul class="delete-impact">
<li>Assigned assets <strong>keep all their data and custom-field values</strong> nothing is removed from them.</li>
<li>Those assets are simply <strong>unlinked</strong> from this template.</li>
<li>The template will <strong>no longer be available</strong> when creating new assets.</li>
</ul>
<p class="quiet text-body-2 mt-3">This action cannot be undone.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete template</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { slugFieldKey } from '../utils/assets';
import { clonePlain } from '../utils/clone';
function fieldId() {
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
}
export default {
components: { DataTable },
props: {
categoryItems: { type: Array, required: true },
customFieldTypes: { type: Array, required: true },
methodItems: { type: Array, required: true },
templateForm: { type: Object, required: true },
templates: { type: Array, required: true }
templates: { type: Array, required: true },
canDelete: { type: Boolean, default: false }
},
emits: ['save-template'],
emits: ['save-template', 'delete-template'],
data() {
return {
localForm: clonePlain(this.templateForm)
localForm: clonePlain(this.templateForm),
deleteDialog: false,
deleteTarget: null,
columns: [
{ key: 'name', label: 'Template' },
{ key: 'description', label: 'Description' },
{ key: 'fields_count', label: 'Fields', format: 'number' },
{ key: 'asset_count', label: 'Assets', format: 'number' }
]
};
},
computed: {
templateRows() {
return this.templates.map((template) => ({
id: template.id,
name: template.name,
description: template.description,
fields_count: (template.custom_fields || []).length,
asset_count: template.asset_count || 0
}));
}
},
watch: {
templateForm: {
handler(value) {
@@ -69,7 +147,7 @@ export default {
methods: {
addField() {
this.localForm.custom_fields.push({
local_id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
local_id: fieldId(),
key: '',
label: '',
type: 'text',
@@ -79,7 +157,59 @@ export default {
},
syncFieldKey(field) {
if (!field.key) field.key = slugFieldKey(field.label);
},
startNew() {
this.localForm = {
id: null,
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
};
},
editTemplate(row) {
const template = this.templates.find((item) => item.id === row.id);
if (!template) return;
this.localForm = {
id: template.id,
name: template.name,
description: template.description || '',
defaults: {
category: 'Computer Equipment',
useful_life_months: 60,
depreciation_method: 'straight_line',
...(template.defaults || {})
},
custom_fields: (template.custom_fields || []).map((f) => ({
local_id: fieldId(),
key: f.key || '',
label: f.label || '',
type: f.type || 'text',
required: Boolean(f.required),
defaultValue: f.defaultValue ?? f.default ?? ''
}))
};
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
performDelete() {
if (this.deleteTarget) this.$emit('delete-template', this.deleteTarget.id);
this.deleteDialog = false;
this.deleteTarget = null;
}
}
};
</script>
<style scoped>
.delete-impact {
padding-left: 20px;
line-height: 1.6;
}
.delete-impact li {
margin-bottom: 4px;
}
</style>