Updated A LOT
This commit is contained in:
747
src/App.vue
747
src/App.vue
File diff suppressed because it is too large
Load Diff
211
src/components/AssetClassSettings.vue
Normal file
211
src/components/AssetClassSettings.vue
Normal 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 category’s 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 won’t appear in the lookup anymore.
|
||||
</template>
|
||||
<template v-else>This class isn’t 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>
|
||||
@@ -10,9 +10,13 @@
|
||||
<v-tab value="details">Details</v-tab>
|
||||
<v-tab value="books">Books</v-tab>
|
||||
<v-tab value="files">Files <v-badge v-if="assetFiles.length" :content="assetFiles.length" inline /></v-tab>
|
||||
<v-tab value="photos">Photos <v-badge v-if="assetPhotos.length" :content="assetPhotos.length" inline /></v-tab>
|
||||
<v-tab value="warranties">Warranties <v-badge v-if="assetWarranties.length" :content="assetWarranties.length" inline /></v-tab>
|
||||
<v-tab value="tasks">Tasks <v-badge v-if="assetTasks.length" :content="assetTasks.length" inline /></v-tab>
|
||||
<v-tab value="notes">Notes <v-badge v-if="assetNotes.length" :content="assetNotes.length" inline /></v-tab>
|
||||
<v-tab value="disposal">Disposal <v-badge v-if="assetDisposals.length" :content="assetDisposals.length" inline /></v-tab>
|
||||
<v-tab value="lease">Lease <v-badge v-if="assetLeases.length" :content="assetLeases.length" inline /></v-tab>
|
||||
<v-tab value="pm">PM <v-badge v-if="assetPm.length" :content="assetPm.length" inline /></v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="pa-4">
|
||||
@@ -21,10 +25,22 @@
|
||||
<v-window-item value="details">
|
||||
<v-select :model-value="selectedTemplateId" :items="templateOptions" label="Template" clearable @update:model-value="$emit('select-template', $event)" />
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="localAsset.asset_id" label="Asset ID" />
|
||||
<v-text-field
|
||||
v-model="localAsset.asset_id"
|
||||
label="Asset ID"
|
||||
:hint="localAsset.id ? '' : 'Leave blank to auto-assign from the category’s ID template'"
|
||||
:persistent-hint="!localAsset.id"
|
||||
/>
|
||||
<v-select v-model="localAsset.entity_id" :items="entityOptions" label="Entity" />
|
||||
<v-text-field v-model="localAsset.description" class="full" label="Description" />
|
||||
<v-select v-model="localAsset.category" :items="categoryItems" label="Category" />
|
||||
<v-text-field
|
||||
:model-value="assetClassNumber"
|
||||
label="Asset class number"
|
||||
readonly
|
||||
:hint="assetClassNumber ? 'Shared by all assets in this category' : 'Set on the category in Admin → Application Configuration'"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-select v-model="localAsset.status" :items="statusItems" label="Status" />
|
||||
<v-text-field v-model.number="localAsset.acquisition_cost" type="number" label="Acquisition cost" />
|
||||
<v-text-field v-model.number="localAsset.salvage_value" type="number" label="Salvage value" />
|
||||
@@ -39,13 +55,23 @@
|
||||
<v-text-field v-model.number="localAsset.investment_use_percent" type="number" label="Investment use %" />
|
||||
<v-switch v-model="localAsset.listed_property" label="Listed property (luxury auto limits)" color="primary" density="compact" hide-details class="full" />
|
||||
|
||||
<v-text-field v-model="localAsset.location" label="Location" />
|
||||
<v-text-field v-model="localAsset.department" label="Department" />
|
||||
<v-select
|
||||
v-model="localAsset.special_zone"
|
||||
:items="zoneItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="Special depreciation zone"
|
||||
class="full"
|
||||
/>
|
||||
<v-alert v-if="zoneGuidance" type="info" variant="tonal" density="compact" class="full">{{ zoneGuidance }}</v-alert>
|
||||
|
||||
<v-combobox v-model="localAsset.location" :items="locationItems" label="Location" clearable />
|
||||
<v-combobox v-model="localAsset.department" :items="departmentItems" label="Department" clearable />
|
||||
<v-text-field v-model="localAsset.group_name" label="Group" />
|
||||
<v-text-field v-model="localAsset.custodian" label="Custodian" />
|
||||
<v-text-field v-model="localAsset.barcode_value" label="Barcode / tag value" />
|
||||
<v-select v-model="localAsset.condition" :items="conditionItems" clearable label="Condition" />
|
||||
<v-text-field v-model="localAsset.vendor" label="Vendor" />
|
||||
<v-combobox v-model="localAsset.vendor" :items="vendorItems" label="Vendor" clearable />
|
||||
<v-text-field v-model="localAsset.serial_number" label="Serial number" />
|
||||
<v-text-field v-model="localAsset.invoice_number" label="Invoice number" />
|
||||
<v-text-field v-model="localAsset.gl_asset_account" label="Asset GL" />
|
||||
@@ -54,6 +80,20 @@
|
||||
<v-textarea v-model="localAsset.notes" class="full" label="Summary notes" rows="2" />
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div class="toolbar-row align-center">
|
||||
<div>
|
||||
<h3 class="section-title">Barcode label</h3>
|
||||
<div class="quiet text-body-2">Uses the barcode value, or the Asset ID when blank.</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" @click="useAssetIdAsBarcode">Use Asset ID</v-btn>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-printer" :disabled="!barcodeValue" @click="$emit('print-label', localAsset)">Print</v-btn>
|
||||
</div>
|
||||
<div v-if="barcodeValue" class="label-cell mt-2" style="max-width:240px">
|
||||
<BarcodeLabel :value="barcodeValue" :caption="localAsset.description" />
|
||||
</div>
|
||||
|
||||
<template v-if="selectedTemplateFields.length">
|
||||
<v-divider class="my-4" />
|
||||
<h3 class="section-title mb-3">Template fields</h3>
|
||||
@@ -130,6 +170,38 @@
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then attach files.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- PHOTOS (identification) -->
|
||||
<v-window-item value="photos">
|
||||
<template v-if="localAsset.id">
|
||||
<div class="toolbar-row mb-3">
|
||||
<div class="quiet text-body-2">Identification photos — what the asset looks like, where it is, nameplates, serial labels.</div>
|
||||
<v-spacer />
|
||||
<v-btn variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.photoInput.click()">Add photos</v-btn>
|
||||
<input ref="photoInput" hidden type="file" accept="image/*" capture="environment" multiple @change="onPhotoPick" />
|
||||
</div>
|
||||
<div v-if="assetPhotos.length" class="asset-photo-grid">
|
||||
<div v-for="photo in assetPhotos" :key="photo.id" class="asset-photo-card">
|
||||
<div class="asset-photo-frame">
|
||||
<img :src="photo.data" :alt="photo.caption || photo.name || 'asset photo'" />
|
||||
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="asset-photo-remove" @click="$emit('delete-photo', photo.id)" />
|
||||
</div>
|
||||
<v-text-field
|
||||
:model-value="photo.caption"
|
||||
density="compact"
|
||||
hide-details
|
||||
placeholder="Caption"
|
||||
class="mt-1"
|
||||
@update:model-value="captionDrafts[photo.id] = $event"
|
||||
@blur="saveCaption(photo)"
|
||||
@keyup.enter="saveCaption(photo)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="quiet text-body-2">No photos yet. On a phone, "Add photos" opens the camera.</p>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then add photos.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- WARRANTIES -->
|
||||
<v-window-item value="warranties">
|
||||
<template v-if="localAsset.id">
|
||||
@@ -211,22 +283,335 @@
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then keep running notes.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- DISPOSAL -->
|
||||
<v-window-item value="disposal">
|
||||
<template v-if="localAsset.id">
|
||||
<v-list v-if="assetDisposals.length" lines="two" density="compact" class="mb-3">
|
||||
<v-list-item v-for="d in assetDisposals" :key="d.id" prepend-icon="mdi-cash-remove">
|
||||
<v-list-item-title>
|
||||
{{ d.partial ? 'Partial' : 'Full' }} {{ d.method }} · {{ d.disposal_date }}
|
||||
<span :class="d.gain_loss >= 0 ? 'text-success' : 'text-error'">
|
||||
{{ d.gain_loss >= 0 ? 'Gain' : 'Loss' }} {{ currency(Math.abs(d.gain_loss)) }}
|
||||
</span>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ d.book_type }} · proceeds {{ currency(d.disposal_price - d.disposal_expense) }} · NBV {{ currency(d.net_book_value) }}
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-undo" size="small" variant="text" @click="$emit('reverse-disposal', d.id)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<h3 class="section-title mb-2">Record disposal</h3>
|
||||
<div class="form-grid">
|
||||
<v-select v-model="disposalForm.book_type" :items="bookItems" label="Book" />
|
||||
<v-select v-model="disposalForm.method" :items="disposalMethodItems" item-title="title" item-value="value" label="Disposal type" />
|
||||
<v-text-field v-model="disposalForm.disposal_date" type="date" label="Disposal date" />
|
||||
<v-select v-model="disposalForm.property_type" :items="propertyTypeItems" item-title="title" item-value="value" label="Property type (Form 4797)" />
|
||||
<v-text-field v-model.number="disposalForm.disposal_price" type="number" label="Sale price" />
|
||||
<v-text-field v-model.number="disposalForm.disposal_expense" type="number" label="Sale expense" />
|
||||
<v-switch v-model="disposalForm.partial" label="Partial disposal" color="primary" density="compact" hide-details class="full" />
|
||||
<v-text-field v-if="disposalForm.partial" v-model.number="disposalForm.disposed_cost" type="number" label="Cost basis disposed" class="full" />
|
||||
<v-textarea v-model="disposalForm.notes" class="full" label="Notes" rows="2" />
|
||||
</div>
|
||||
<div class="toolbar-row mt-2">
|
||||
<v-btn variant="tonal" prepend-icon="mdi-calculator" @click="$emit('preview-disposal', { ...disposalForm })">Calculate gain/loss</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn color="error" prepend-icon="mdi-cash-remove" @click="$emit('record-disposal', { ...disposalForm })">Record disposal</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="disposalPreview" class="mt-3" density="compact" :type="disposalPreview.gain_loss >= 0 ? 'success' : 'warning'">
|
||||
Accumulated depreciation {{ currency(disposalPreview.accumulated_depreciation) }} ·
|
||||
Net book value {{ currency(disposalPreview.net_book_value) }} ·
|
||||
Proceeds {{ currency(disposalPreview.realized) }} →
|
||||
<strong>{{ disposalPreview.gain_loss >= 0 ? 'Gain' : 'Loss' }} of {{ currency(Math.abs(disposalPreview.gain_loss)) }}</strong>
|
||||
</v-alert>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<h3 class="section-title mb-2">Impairments & adjustments</h3>
|
||||
<v-list v-if="assetAdjustments.length" density="compact" class="mb-2">
|
||||
<v-list-item v-for="adj in assetAdjustments" :key="adj.id" prepend-icon="mdi-trending-down">
|
||||
<v-list-item-title>{{ adj.type }} · {{ currency(adj.amount) }} ({{ adj.book_type }})</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ adj.adjustment_date }} · {{ adj.reason || '—' }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('delete-adjustment', adj.id)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div class="form-grid">
|
||||
<v-select v-model="adjustmentForm.type" :items="adjustmentTypeItems" item-title="title" item-value="value" label="Type" />
|
||||
<v-select v-model="adjustmentForm.book_type" :items="bookItems" label="Book" />
|
||||
<v-text-field v-model.number="adjustmentForm.amount" type="number" label="Amount" />
|
||||
<v-text-field v-model="adjustmentForm.adjustment_date" type="date" label="Date" />
|
||||
<v-text-field v-model="adjustmentForm.reason" class="full" label="Reason" />
|
||||
</div>
|
||||
<v-btn variant="tonal" prepend-icon="mdi-plus" class="mt-2" :disabled="!adjustmentForm.amount" @click="addAdjustment">Add adjustment</v-btn>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then record a disposal.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- LEASE -->
|
||||
<v-window-item value="lease">
|
||||
<template v-if="localAsset.id">
|
||||
<v-list v-if="assetLeases.length" lines="two" density="compact" class="mb-3">
|
||||
<v-list-item
|
||||
v-for="lease in assetLeases"
|
||||
:key="lease.id"
|
||||
:active="selectedLeaseId === lease.id"
|
||||
prepend-icon="mdi-file-document-outline"
|
||||
@click="$emit('load-lease-schedule', lease.id)"
|
||||
>
|
||||
<v-list-item-title>{{ lease.lessor || 'Lease' }} · {{ currency(lease.contract_value) }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ lease.start_date }} → {{ lease.end_date }} · {{ lease.payment_frequency }} · {{ lease.discount_rate }}%</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click.stop="$emit('delete-lease', lease.id)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<h3 class="section-title mb-2">Add lease</h3>
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="leaseForm.lessor" label="Lessor" />
|
||||
<v-text-field v-model.number="leaseForm.contract_value" type="number" label="Contract value (total payments)" />
|
||||
<v-text-field v-model="leaseForm.start_date" type="date" label="Start date" />
|
||||
<v-text-field v-model="leaseForm.end_date" type="date" label="End date" />
|
||||
<v-text-field v-model.number="leaseForm.discount_rate" type="number" label="Discount rate %" />
|
||||
<v-select v-model="leaseForm.payment_frequency" :items="leaseFrequencyItems" item-title="title" item-value="value" label="Payment frequency" />
|
||||
<v-textarea v-model="leaseForm.notes" class="full" label="Notes" rows="2" />
|
||||
</div>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" @click="addLease">Add lease</v-btn>
|
||||
|
||||
<template v-if="leaseSchedule">
|
||||
<v-divider class="my-4" />
|
||||
<h3 class="section-title mb-1">Amortization schedule</h3>
|
||||
<p class="quiet text-body-2 mb-2">
|
||||
ROU asset / present value {{ currency(leaseSchedule.summary.present_value) }} ·
|
||||
{{ leaseSchedule.summary.periods }} payments of {{ currency(leaseSchedule.summary.periodic_payment) }} ·
|
||||
interest {{ currency(leaseSchedule.summary.total_interest) }}
|
||||
</p>
|
||||
<div style="overflow-x:auto;max-height:280px">
|
||||
<table class="asset-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Date</th><th>Payment</th><th>Interest</th><th>Principal</th><th>Liability</th><th>ROU bal.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in leaseSchedule.rows" :key="row.period">
|
||||
<td>{{ row.period }}</td>
|
||||
<td>{{ row.date }}</td>
|
||||
<td>{{ currency(row.payment) }}</td>
|
||||
<td>{{ currency(row.interest) }}</td>
|
||||
<td>{{ currency(row.principal) }}</td>
|
||||
<td>{{ currency(row.liability_balance) }}</td>
|
||||
<td>{{ currency(row.rou_balance) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then capture a lease.</p>
|
||||
</v-window-item>
|
||||
|
||||
<!-- PREVENTATIVE MAINTENANCE -->
|
||||
<v-window-item value="pm">
|
||||
<template v-if="localAsset.id">
|
||||
<v-list v-if="assetPm.length" lines="two" density="compact" class="mb-3">
|
||||
<v-list-item v-for="pm in assetPm" :key="pm.id" :prepend-icon="pm.active ? 'mdi-wrench-clock' : 'mdi-check-circle-outline'">
|
||||
<v-list-item-title>{{ pm.plan_name || 'Maintenance' }} · every {{ pm.frequency_value }} {{ unitLabel(pm.frequency_unit) }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<template v-if="pm.active">
|
||||
Next due {{ pm.next_due_date || '—' }}<template v-if="pm.last_completed_date"> · last done {{ pm.last_completed_date }}</template>
|
||||
</template>
|
||||
<template v-else>Schedule complete (past {{ pm.end_date }})</template>
|
||||
<template v-if="pm.total_cost"> · spent {{ currency(pm.total_cost) }}</template>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn v-if="pm.active" size="small" variant="text" color="primary" @click="openComplete(pm)">Complete</v-btn>
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('detach-pm', pm.id)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<template v-if="pmCompletions.length">
|
||||
<h3 class="section-title mb-2">Completed forms</h3>
|
||||
<v-list density="compact" class="mb-3">
|
||||
<v-list-item v-for="c in pmCompletions" :key="c.id" prepend-icon="mdi-clipboard-check-outline">
|
||||
<v-list-item-title>{{ c.plan_name || 'Maintenance' }} · {{ (c.completed_at || '').slice(0, 10) }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
by {{ c.completed_by_name || 'Unknown' }} · safety {{ c.ratings.safety || '–' }}★
|
||||
<template v-if="c.photo_count"> · {{ c.photo_count }} photo(s)</template>
|
||||
<template v-if="c.has_signature"> · signed</template>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn size="small" variant="text" @click="$emit('view-completion', c.id)">View</v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider class="mb-3" />
|
||||
</template>
|
||||
|
||||
<v-divider class="mb-3" />
|
||||
<h3 class="section-title mb-2">Attach a PM plan</h3>
|
||||
<div class="form-grid">
|
||||
<v-select v-model="pmForm.plan_id" :items="planOptions" item-title="title" item-value="value" label="Plan" class="full" @update:model-value="applyPlanDefaults" />
|
||||
<v-text-field v-model.number="pmForm.frequency_value" type="number" label="Every" />
|
||||
<v-select v-model="pmForm.frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Frequency unit" />
|
||||
<v-text-field v-model="pmForm.start_date" type="date" label="Start / first due" />
|
||||
<v-text-field v-model="pmForm.end_date" type="date" label="End date (depreciation end)" />
|
||||
</div>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" :disabled="!pmForm.plan_id" @click="attachPm">Attach plan</v-btn>
|
||||
</template>
|
||||
<p v-else class="quiet text-body-2">Save the asset first, then attach PM plans.</p>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
|
||||
<v-alert v-if="drawerError" class="mt-4" density="compact" type="error">{{ drawerError }}</v-alert>
|
||||
<div class="toolbar-row mt-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="$emit('save-asset', localAsset)">Save asset</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="attemptSave">Save asset</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="completeDialog" max-width="620" scrollable>
|
||||
<v-card>
|
||||
<v-card-title>Complete: {{ completeTarget?.plan_name || 'Maintenance' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="quiet text-body-2 mb-3">Closing as <strong>{{ currentUserName || 'current user' }}</strong></div>
|
||||
|
||||
<template v-if="completeGuidancePhotos.length">
|
||||
<h3 class="section-title mb-1">Guidance photos</h3>
|
||||
<div class="pm-guidance-grid mb-3">
|
||||
<figure v-for="photo in completeGuidancePhotos" :key="photo.id" class="pm-guidance-fig">
|
||||
<img :src="photo.data" :alt="photo.caption || 'guidance'" />
|
||||
<figcaption v-if="photo.caption" class="quiet text-caption">{{ photo.caption }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h3 class="section-title mb-1">Steps</h3>
|
||||
<p v-if="!completeSteps.length" class="quiet text-body-2">This plan has no steps.</p>
|
||||
<div v-for="(step, i) in completeSteps" :key="i" class="d-flex align-start mb-1">
|
||||
<v-checkbox-btn v-model="step.done" />
|
||||
<div class="mt-1">
|
||||
<div>{{ step.title }}</div>
|
||||
<div v-if="step.details" class="quiet text-caption">{{ step.details }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
<h3 class="section-title mb-2">Condition ratings</h3>
|
||||
<div v-for="q in ratingQuestions" :key="q.key" class="d-flex align-center mb-1">
|
||||
<div class="text-body-2" style="min-width:230px">{{ q.label }}</div>
|
||||
<v-rating v-model="completeRatings[q.key]" length="5" color="amber" active-color="amber" density="compact" hover />
|
||||
</div>
|
||||
|
||||
<template v-if="completeComponents.length">
|
||||
<v-divider class="my-3" />
|
||||
<div class="text-body-2 font-weight-medium mb-1">Parts used</div>
|
||||
<div v-for="(c, i) in completeComponents" :key="`cc${i}`" class="d-flex text-body-2 mb-1">
|
||||
<span>{{ c.part_number ? c.part_number + ' · ' : '' }}{{ c.description }}<span v-if="c.supplier" class="quiet"> ({{ c.supplier }})</span></span>
|
||||
<v-spacer />
|
||||
<span>{{ currency(c.cost) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-text-field v-model.number="completeCost" type="number" prefix="$" label="Service cost" density="compact" hide-details class="mt-3" />
|
||||
|
||||
<v-divider class="my-3" />
|
||||
<div class="toolbar-row mb-1">
|
||||
<h3 class="section-title">Completion notes</h3>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="tonal" prepend-icon="mdi-plus" @click="completeNotesList.push('')">Add note</v-btn>
|
||||
</div>
|
||||
<div v-for="(note, i) in completeNotesList" :key="`note${i}`" class="d-flex align-center mb-1" style="gap:6px">
|
||||
<v-text-field v-model="completeNotesList[i]" density="compact" hide-details placeholder="Note" />
|
||||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="completeNotesList.splice(i, 1)" />
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
<div class="toolbar-row mb-1">
|
||||
<h3 class="section-title">Photos</h3>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.pmPhotoInput.click()">Add photos</v-btn>
|
||||
<input ref="pmPhotoInput" hidden type="file" accept="image/*" multiple @change="onCompletePhotos" />
|
||||
</div>
|
||||
<div v-if="completePhotos.length" class="pm-photo-grid">
|
||||
<div v-for="(p, i) in completePhotos" :key="`p${i}`" class="pm-photo-wrap">
|
||||
<img :src="p.data" class="pm-photo-thumb" :alt="p.name || 'maintenance photo'" />
|
||||
<v-btn icon="mdi-close" size="x-small" class="pm-photo-remove" @click="completePhotos.splice(i, 1)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
<h3 class="section-title mb-1">Signature <span class="text-error">*</span></h3>
|
||||
<SignaturePad v-model="completeSignature" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="completeDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-check" :disabled="!completeSignature" @click="submitComplete">Mark complete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Prompt shown when depreciation-critical fields change on an already-depreciated asset -->
|
||||
<v-dialog v-model="applyDialog" max-width="560">
|
||||
<v-card>
|
||||
<v-card-title>Apply depreciation change</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-2">You changed depreciation-critical fields ({{ changedCriticalLabels }}).</p>
|
||||
<p class="mb-3 quiet text-body-2">Choose when the change takes effect:</p>
|
||||
<v-radio-group v-model="applyChoice" hide-details>
|
||||
<v-radio value="placed_in_service">
|
||||
<template #label>
|
||||
<div>
|
||||
<div>Placed-in-service date</div>
|
||||
<div class="quiet text-caption">Rebuild the entire depreciation history from the start (clears prior depreciation).</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-radio>
|
||||
<v-radio value="going_forward" class="mt-2">
|
||||
<template #label>
|
||||
<div>
|
||||
<div>Current period — going forward</div>
|
||||
<div class="quiet text-caption">Keep depreciation already recorded; apply the change to remaining periods.</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-radio>
|
||||
</v-radio-group>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="applyDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="confirmApply">Save asset</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BarcodeLabel from './BarcodeLabel.vue';
|
||||
import SignaturePad from './SignaturePad.vue';
|
||||
import { inputTypeForCustomField, makeDefaultBooks } from '../utils/assets';
|
||||
import { clonePlain } from '../utils/clone';
|
||||
import { conditionItems, conventionItems, newUsedItems, taskTypeItems } from '../constants';
|
||||
import { currency } from '../utils/format';
|
||||
import {
|
||||
adjustmentTypeItems,
|
||||
conditionItems,
|
||||
conventionItems,
|
||||
disposalMethodItems,
|
||||
leaseFrequencyItems,
|
||||
newUsedItems,
|
||||
pmFrequencyUnits,
|
||||
propertyTypeItems,
|
||||
taskTypeItems
|
||||
} from '../constants';
|
||||
|
||||
function blankWarranty() {
|
||||
return { provider: '', phone: '', start_date: '', expiration_date: '', warranty_limit: '', actual_usage: '', coverage_details: '' };
|
||||
@@ -236,19 +621,60 @@ function blankTask() {
|
||||
return { title: '', type: 'maintenance', due_date: '', notes: '' };
|
||||
}
|
||||
|
||||
function blankDisposal() {
|
||||
return {
|
||||
book_type: 'GAAP',
|
||||
method: 'sale',
|
||||
disposal_date: new Date().toISOString().slice(0, 10),
|
||||
property_type: '1245',
|
||||
disposal_price: 0,
|
||||
disposal_expense: 0,
|
||||
partial: false,
|
||||
disposed_cost: 0,
|
||||
notes: ''
|
||||
};
|
||||
}
|
||||
|
||||
function blankAdjustment() {
|
||||
return { type: 'impairment', book_type: 'GAAP', amount: 0, adjustment_date: new Date().toISOString().slice(0, 10), reason: '' };
|
||||
}
|
||||
|
||||
function blankLease() {
|
||||
return { lessor: '', contract_value: 0, start_date: '', end_date: '', discount_rate: 0, payment_frequency: 'monthly', notes: '' };
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { BarcodeLabel, SignaturePad },
|
||||
props: {
|
||||
assetForm: { type: Object, required: true },
|
||||
assetAdjustments: { type: Array, default: () => [] },
|
||||
assetDisposals: { type: Array, default: () => [] },
|
||||
assetFiles: { type: Array, default: () => [] },
|
||||
assetLeases: { type: Array, default: () => [] },
|
||||
assetNotes: { type: Array, default: () => [] },
|
||||
assetPhotos: { type: Array, default: () => [] },
|
||||
assetPm: { type: Array, default: () => [] },
|
||||
assetTasks: { type: Array, default: () => [] },
|
||||
assetWarranties: { type: Array, default: () => [] },
|
||||
bookItems: { type: Array, default: () => [] },
|
||||
pmPlans: { type: Array, default: () => [] },
|
||||
pmDefaults: { type: Object, default: () => ({ value: 3, unit: 'months' }) },
|
||||
currentUserName: { type: String, default: '' },
|
||||
categoryItems: { type: Array, required: true },
|
||||
disposalPreview: { type: Object, default: null },
|
||||
drawerError: { type: String, default: '' },
|
||||
entityOptions: { type: Array, required: true },
|
||||
locationItems: { type: Array, default: () => [] },
|
||||
departmentItems: { type: Array, default: () => [] },
|
||||
vendorItems: { type: Array, default: () => [] },
|
||||
categoryClassNumbers: { type: Object, default: () => ({}) },
|
||||
zoneItems: { type: Array, default: () => [] },
|
||||
zoneDetails: { type: Object, default: () => ({}) },
|
||||
leaseSchedule: { type: Object, default: null },
|
||||
methodItems: { type: Array, required: true },
|
||||
modelValue: { type: Boolean, default: false },
|
||||
saving: { type: Boolean, default: false },
|
||||
selectedLeaseId: { type: [Number, String], default: null },
|
||||
selectedTemplateFields: { type: Array, required: true },
|
||||
selectedTemplateId: { type: [Number, String], default: null },
|
||||
statusItems: { type: Array, required: true },
|
||||
@@ -259,26 +685,118 @@ export default {
|
||||
'upload-file', 'download-file', 'delete-file',
|
||||
'add-warranty', 'delete-warranty',
|
||||
'add-task', 'toggle-task', 'delete-task',
|
||||
'add-note', 'delete-note'
|
||||
'add-note', 'delete-note',
|
||||
'upload-photo', 'update-photo', 'delete-photo',
|
||||
'preview-disposal', 'record-disposal', 'reverse-disposal',
|
||||
'add-adjustment', 'delete-adjustment',
|
||||
'save-lease', 'delete-lease', 'load-lease-schedule',
|
||||
'print-label',
|
||||
'attach-pm', 'detach-pm', 'complete-pm', 'view-completion'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
tab: 'details',
|
||||
adjustmentTypeItems,
|
||||
conditionItems,
|
||||
conventionItems,
|
||||
disposalMethodItems,
|
||||
leaseFrequencyItems,
|
||||
newUsedItems,
|
||||
pmFrequencyUnits,
|
||||
propertyTypeItems,
|
||||
taskTypeItems,
|
||||
localAsset: this.normalize(this.assetForm),
|
||||
warrantyForm: blankWarranty(),
|
||||
taskForm: blankTask(),
|
||||
disposalForm: blankDisposal(),
|
||||
adjustmentForm: blankAdjustment(),
|
||||
leaseForm: blankLease(),
|
||||
pmForm: { plan_id: null, frequency_value: 3, frequency_unit: 'months', start_date: '', end_date: '' },
|
||||
completeDialog: false,
|
||||
completeTarget: null,
|
||||
completeSteps: [],
|
||||
completeGuidancePhotos: [],
|
||||
completeComponents: [],
|
||||
completeCost: 0,
|
||||
completeNotesList: [],
|
||||
completeRatings: { safety: 0, physical: 0, operating: 0 },
|
||||
completeSignature: '',
|
||||
completePhotos: [],
|
||||
ratingQuestions: [
|
||||
{ key: 'safety', label: 'Safety condition / Is it safe?' },
|
||||
{ key: 'physical', label: 'Physical condition / appearance' },
|
||||
{ key: 'operating', label: 'Operating condition / functionality' }
|
||||
],
|
||||
noteDraft: '',
|
||||
captionDrafts: {},
|
||||
criticalSnapshot: null,
|
||||
applyDialog: false,
|
||||
applyChoice: 'placed_in_service',
|
||||
requiredRule: (value) => value !== null && value !== undefined && value !== '' || 'Required'
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
barcodeValue() {
|
||||
return (this.localAsset.barcode_value || this.localAsset.asset_id || '').trim();
|
||||
},
|
||||
assetClassNumber() {
|
||||
// Live from the selected category; fall back to the value stored on the asset.
|
||||
return this.categoryClassNumbers[this.localAsset.category] || this.localAsset.asset_class_number || '';
|
||||
},
|
||||
zoneGuidance() {
|
||||
const zone = this.zoneDetails[this.localAsset.special_zone];
|
||||
if (!zone) return '';
|
||||
const pis = this.localAsset.in_service_date || this.localAsset.acquired_date || '';
|
||||
const inWindow = (!zone.pis_start || !pis || pis >= zone.pis_start) && (!zone.pis_end || !pis || pis <= zone.pis_end);
|
||||
const base = `${zone.name}: ${zone.allowance_percent}% §168 allowance applied to the FEDERAL book`
|
||||
+ `${zone.pis_start || zone.pis_end ? ` for assets placed in service ${zone.pis_start || '…'} to ${zone.pis_end || '…'}` : ''}.`;
|
||||
const lease = zone.leasehold_life_years ? ` Qualified leasehold improvements use a ${zone.leasehold_life_years}-year life.` : '';
|
||||
if (pis && !inWindow) return `${base} This asset's placed-in-service date is OUTSIDE the window, so the allowance will not apply.${lease}`;
|
||||
return `${base}${lease}`;
|
||||
},
|
||||
changedCriticalLabels() {
|
||||
if (!this.criticalSnapshot) return 'depreciation settings';
|
||||
const current = this.criticalObject(this.localAsset);
|
||||
const snap = this.criticalSnapshot;
|
||||
const labels = {
|
||||
property_type: 'property type',
|
||||
in_service_date: 'placed-in-service date',
|
||||
acquisition_cost: 'acquisition value',
|
||||
useful_life_months: 'estimated life',
|
||||
salvage_value: 'salvage value',
|
||||
business_use_percent: 'business-use %',
|
||||
depreciation_method: 'depreciation method',
|
||||
special_zone: 'special depreciation zone'
|
||||
};
|
||||
const changed = Object.keys(labels).filter((key) => JSON.stringify(current[key]) !== JSON.stringify(snap[key])).map((key) => labels[key]);
|
||||
if (JSON.stringify(current.books) !== JSON.stringify(snap.books)) changed.push('book settings (method, life, §179, §168, cost, or business-use)');
|
||||
return changed.join(', ') || 'depreciation settings';
|
||||
},
|
||||
planOptions() {
|
||||
return this.pmPlans.map((plan) => ({
|
||||
title: `${plan.name} (every ${plan.frequency_value} ${this.unitLabel(plan.frequency_unit)})`,
|
||||
value: plan.id
|
||||
}));
|
||||
},
|
||||
depreciationEnd() {
|
||||
const start = this.localAsset.in_service_date;
|
||||
const months = Number(this.localAsset.useful_life_months) || 0;
|
||||
if (!start || !months) return '';
|
||||
const date = new Date(`${start}T00:00:00`);
|
||||
date.setMonth(date.getMonth() + months);
|
||||
return date.toISOString().slice(0, 10);
|
||||
},
|
||||
pmCompletions() {
|
||||
return (this.assetPm || [])
|
||||
.flatMap((pm) => (pm.completions || []).map((c) => ({ ...c, plan_name: pm.plan_name })))
|
||||
.sort((a, b) => String(b.completed_at).localeCompare(String(a.completed_at)));
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
assetForm: {
|
||||
handler(value) {
|
||||
this.localAsset = this.normalize(value);
|
||||
this.criticalSnapshot = this.criticalObject(this.localAsset);
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
@@ -287,12 +805,18 @@ export default {
|
||||
this.tab = 'details';
|
||||
this.warrantyForm = blankWarranty();
|
||||
this.taskForm = blankTask();
|
||||
this.disposalForm = blankDisposal();
|
||||
this.adjustmentForm = blankAdjustment();
|
||||
this.leaseForm = blankLease();
|
||||
this.resetPmForm();
|
||||
this.noteDraft = '';
|
||||
this.criticalSnapshot = this.criticalObject(this.localAsset);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
inputTypeForCustomField,
|
||||
currency,
|
||||
normalize(value) {
|
||||
const asset = clonePlain(value);
|
||||
if (!Array.isArray(asset.books) || !asset.books.length) {
|
||||
@@ -301,6 +825,46 @@ export default {
|
||||
if (!asset.custom_fields || typeof asset.custom_fields !== 'object') asset.custom_fields = {};
|
||||
return asset;
|
||||
},
|
||||
// Snapshot of the depreciation-critical fields, used to detect changes on save.
|
||||
criticalObject(asset) {
|
||||
return {
|
||||
property_type: asset.property_type ?? null,
|
||||
in_service_date: asset.in_service_date ?? null,
|
||||
acquisition_cost: Number(asset.acquisition_cost || 0),
|
||||
useful_life_months: Number(asset.useful_life_months || 0),
|
||||
salvage_value: Number(asset.salvage_value || 0),
|
||||
business_use_percent: Number(asset.business_use_percent || 0),
|
||||
depreciation_method: asset.depreciation_method ?? null,
|
||||
special_zone: asset.special_zone ?? null,
|
||||
books: (asset.books || []).map((b) => ({
|
||||
book_type: b.book_type,
|
||||
cost: b.cost ?? null,
|
||||
depreciation_method: b.depreciation_method ?? null,
|
||||
useful_life_months: b.useful_life_months ?? null,
|
||||
section_179_amount: Number(b.section_179_amount || 0),
|
||||
bonus_percent: Number(b.bonus_percent || 0),
|
||||
business_use_percent: b.business_use_percent ?? null,
|
||||
convention: b.convention ?? null,
|
||||
active: b.active !== false
|
||||
}))
|
||||
};
|
||||
},
|
||||
hasCriticalChange() {
|
||||
return this.criticalSnapshot && JSON.stringify(this.criticalObject(this.localAsset)) !== JSON.stringify(this.criticalSnapshot);
|
||||
},
|
||||
attemptSave() {
|
||||
// Existing, already-saved asset whose depreciation inputs changed → ask when to apply.
|
||||
if (this.localAsset.id && this.hasCriticalChange()) {
|
||||
this.applyChoice = 'placed_in_service';
|
||||
this.applyDialog = true;
|
||||
} else {
|
||||
this.$emit('save-asset', this.localAsset);
|
||||
}
|
||||
},
|
||||
confirmApply() {
|
||||
this.$emit('save-asset', { ...this.localAsset, depreciation_apply: this.applyChoice });
|
||||
this.applyDialog = false;
|
||||
},
|
||||
methodLabel(code) {
|
||||
return this.methodItems.find((item) => item.value === code)?.title || code;
|
||||
},
|
||||
@@ -315,6 +879,19 @@ export default {
|
||||
if (file) this.$emit('upload-file', file);
|
||||
event.target.value = '';
|
||||
},
|
||||
onPhotoPick(event) {
|
||||
for (const file of Array.from(event.target.files || [])) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => this.$emit('upload-photo', { name: file.name, mime: file.type, data: String(reader.result) });
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
},
|
||||
saveCaption(photo) {
|
||||
const caption = this.captionDrafts[photo.id];
|
||||
if (caption === undefined || caption === photo.caption) return;
|
||||
this.$emit('update-photo', { id: photo.id, caption });
|
||||
},
|
||||
addWarranty() {
|
||||
this.$emit('add-warranty', { ...this.warrantyForm });
|
||||
this.warrantyForm = blankWarranty();
|
||||
@@ -326,6 +903,73 @@ export default {
|
||||
addNote() {
|
||||
this.$emit('add-note', this.noteDraft.trim());
|
||||
this.noteDraft = '';
|
||||
},
|
||||
addAdjustment() {
|
||||
this.$emit('add-adjustment', { ...this.adjustmentForm });
|
||||
this.adjustmentForm = blankAdjustment();
|
||||
},
|
||||
addLease() {
|
||||
this.$emit('save-lease', { ...this.leaseForm });
|
||||
this.leaseForm = blankLease();
|
||||
},
|
||||
useAssetIdAsBarcode() {
|
||||
this.localAsset.barcode_value = this.localAsset.asset_id || '';
|
||||
},
|
||||
unitLabel(unit) {
|
||||
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
|
||||
},
|
||||
resetPmForm() {
|
||||
this.pmForm = {
|
||||
plan_id: null,
|
||||
frequency_value: this.pmDefaults?.value || 3,
|
||||
frequency_unit: this.pmDefaults?.unit || 'months',
|
||||
start_date: new Date().toISOString().slice(0, 10),
|
||||
end_date: this.depreciationEnd
|
||||
};
|
||||
},
|
||||
applyPlanDefaults(planId) {
|
||||
const plan = this.pmPlans.find((p) => p.id === planId);
|
||||
if (plan) {
|
||||
this.pmForm.frequency_value = plan.frequency_value;
|
||||
this.pmForm.frequency_unit = plan.frequency_unit;
|
||||
}
|
||||
},
|
||||
attachPm() {
|
||||
this.$emit('attach-pm', { ...this.pmForm, next_due_date: this.pmForm.start_date });
|
||||
this.resetPmForm();
|
||||
},
|
||||
openComplete(pm) {
|
||||
this.completeTarget = pm;
|
||||
this.completeSteps = (pm.steps || []).map((s) => ({ title: s.title, details: s.details, done: false }));
|
||||
this.completeGuidancePhotos = pm.plan_photos || [];
|
||||
this.completeComponents = (pm.components || []).map((c) => ({ ...c }));
|
||||
this.completeCost = this.completeComponents.reduce((sum, c) => sum + (Number(c.cost) || 0), 0);
|
||||
this.completeNotesList = [''];
|
||||
this.completeRatings = { safety: 0, physical: 0, operating: 0 };
|
||||
this.completeSignature = '';
|
||||
this.completePhotos = [];
|
||||
this.completeDialog = true;
|
||||
},
|
||||
onCompletePhotos(event) {
|
||||
for (const file of Array.from(event.target.files || [])) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => this.completePhotos.push({ name: file.name, mime: file.type, data: String(reader.result) });
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
},
|
||||
submitComplete() {
|
||||
this.$emit('complete-pm', {
|
||||
apId: this.completeTarget.id,
|
||||
steps: this.completeSteps.map((s) => ({ title: s.title, done: s.done })),
|
||||
components: this.completeComponents,
|
||||
cost: this.completeCost,
|
||||
notes_list: this.completeNotesList.map((n) => String(n || '').trim()).filter(Boolean),
|
||||
ratings: { ...this.completeRatings },
|
||||
signature: this.completeSignature,
|
||||
photos: this.completePhotos
|
||||
});
|
||||
this.completeDialog = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
224
src/components/AssetIdTemplateSettings.vue
Normal file
224
src/components/AssetIdTemplateSettings.vue
Normal 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>
|
||||
50
src/components/BarcodeLabel.vue
Normal file
50
src/components/BarcodeLabel.vue
Normal 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>
|
||||
267
src/components/CategorySettings.vue
Normal file
267
src/components/CategorySettings.vue
Normal 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 form’s 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>
|
||||
205
src/components/DataTable.vue
Normal file
205
src/components/DataTable.vue
Normal 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>
|
||||
186
src/components/DepartmentSettings.vue
Normal file
186
src/components/DepartmentSettings.vue
Normal 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 form’s 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>
|
||||
184
src/components/DepreciationZoneSettings.vue
Normal file
184
src/components/DepreciationZoneSettings.vue
Normal 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 zone’s §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>
|
||||
80
src/components/LabelSheet.vue
Normal file
80
src/components/LabelSheet.vue
Normal 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>
|
||||
76
src/components/MonacoJsonEditor.vue
Normal file
76
src/components/MonacoJsonEditor.vue
Normal 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>
|
||||
115
src/components/NotificationSettings.vue
Normal file
115
src/components/NotificationSettings.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<v-card class="span-12 panel-pad">
|
||||
<h2 class="section-title mb-1">Email & 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>
|
||||
187
src/components/PartCategorySettings.vue
Normal file
187
src/components/PartCategorySettings.vue
Normal 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>
|
||||
187
src/components/PmCategorySettings.vue
Normal file
187
src/components/PmCategorySettings.vue
Normal 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>
|
||||
122
src/components/PmCompletionDialog.vue
Normal file
122
src/components/PmCompletionDialog.vue
Normal 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>
|
||||
63
src/components/PmSettings.vue
Normal file
63
src/components/PmSettings.vue
Normal 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>
|
||||
213
src/components/ServiceNowSettings.vue
Normal file
213
src/components/ServiceNowSettings.vue
Normal 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>
|
||||
80
src/components/SignaturePad.vue
Normal file
80
src/components/SignaturePad.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
994
src/components/UserManual.vue
Normal file
994
src/components/UserManual.vue
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 theme’s 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 you’re 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 system’s “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 & 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 it’s 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 & 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 & 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 & 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 book’s net book value at the disposal date, so the figure reflects that book’s
|
||||
method and conventions. Disposals also feed the disposal and gain/loss reports.
|
||||
</v-alert>
|
||||
|
||||
<h3>Impairments & 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 asset’s 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 & 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
|
||||
asset’s <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 isn’t available,
|
||||
manual code entry works everywhere.
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<!-- BOOKS / DEPRECIATION -->
|
||||
<section v-show="isShown('books')" class="manual-section">
|
||||
<h2>Books, depreciation & 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 book’s 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 jurisdiction’s 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 book’s 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 asset’s Books tab.</li>
|
||||
<li>Delete a book that isn’t a primary or maintenance book and isn’t 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
|
||||
period’s depreciation expense rolled to GL accounts, with per-asset detail and a summary. You can export the ledger.
|
||||
</p>
|
||||
|
||||
<h3>Depreciation methods & critical fields</h3>
|
||||
<p>
|
||||
Each book’s 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 asset’s Details tab (e.g. <strong>New York Liberty Zone</strong>) and the
|
||||
engine automatically applies the zone’s §168 special allowance (30% for NY Liberty) to the <strong>federal book</strong> when the
|
||||
asset’s placed-in-service date falls within the zone’s 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 jurisdiction’s caps for reference. The actual §179 and bonus amounts applied to a given
|
||||
asset are the values you enter on that asset’s <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 asset’s Books tab, the engine
|
||||
matches your choice by its <code>code</code> and computes depreciation using that entry’s 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 book’s “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 book’s 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 year’s 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 & 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 plan’s 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 asset’s 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 asset’s <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 1–5★ <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 asset’s 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 & 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 & 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 form’s <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 & 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 alert’s <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 incident’s 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 & 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 & roles</h3>
|
||||
<ul>
|
||||
<li>Create and edit users — name, email, team, role, status, and password.</li>
|
||||
<li>The list paginates and is filterable. A user’s 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 can’t be deleted until they’re 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 & 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 —
|
||||
it’s 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
|
||||
category’s 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 form’s <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 it’s 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 asset’s 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 form’s <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 & 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 & permissions</h2>
|
||||
<p>
|
||||
Every user has one role, and a role is a set of <strong>capabilities</strong> (granular permissions such as “Create & edit
|
||||
assets” or “Manage integrations”). Capabilities decide which screens appear and which actions are allowed. Roles are managed
|
||||
in <strong>Admin → Users & 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 & 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 & 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 can’t 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 & 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 can’t be restricted.</li>
|
||||
<li><strong>Delete</strong> a custom role once no users are assigned to it. Built-in roles can’t be deleted.</li>
|
||||
<li>Assign a role to a user in the user’s 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 isn’t there, your role likely doesn’t 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>
|
||||
162
src/components/WebhookSettings.vue
Normal file
162
src/components/WebhookSettings.vue
Normal 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>
|
||||
148
src/constants.js
148
src/constants.js
@@ -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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
56
src/main.js
56
src/main.js
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & 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 can’t 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
219
src/views/AlertsView.vue
Normal 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 & 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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
357
src/views/BooksView.vue
Normal 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
253
src/views/ContactsView.vue
Normal 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>
|
||||
@@ -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
509
src/views/PartsView.vue
Normal 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 & 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
363
src/views/PmView.vue
Normal 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>
|
||||
@@ -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
224
src/views/ScanView.vue
Normal 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
254
src/views/TaxRulesView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user