285 lines
10 KiB
Vue
285 lines
10 KiB
Vue
<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-asset_class_number="{ row }">
|
||
<span v-if="row.asset_class_label" :title="row.asset_class_label">{{ row.asset_class_label }}</span>
|
||
<span v-else-if="row.asset_class_number">{{ row.asset_class_number }}</span>
|
||
<span v-else class="quiet">—</span>
|
||
</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
|
||
@update:model-value="onClassNumberInput"
|
||
/>
|
||
<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_id: null, 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' };
|
||
// Value is the class's unique id — class numbers are not unique, so an index/number would
|
||
// collide (e.g. 00.12 is both Computers and Casinos).
|
||
return this.pickableClasses.map((c) => ({
|
||
title: `${c.class_number} — ${c.description} (GDS ${c.gds ?? '—'} / ADS ${c.ads ?? '—'}) · ${tableLabels[c.table] || 'Common'}`,
|
||
value: c.id
|
||
}));
|
||
},
|
||
classHint() {
|
||
const match = this.form.asset_class_id ? this.assetClasses.find((c) => c.id === this.form.asset_class_id) : null;
|
||
if (!match) {
|
||
return this.form.asset_class_number ? `Manual entry — not linked to a catalog class.` : '';
|
||
}
|
||
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(id) {
|
||
const entry = id != null ? this.assetClasses.find((c) => c.id === id) : null;
|
||
if (entry) {
|
||
this.form.asset_class_id = entry.id;
|
||
this.form.asset_class_number = entry.class_number;
|
||
}
|
||
this.$nextTick(() => { this.classLookup = null; });
|
||
},
|
||
onClassNumberInput() {
|
||
// Typing a number by hand detaches it from a catalog class (manual override).
|
||
this.form.asset_class_id = null;
|
||
},
|
||
openNew() {
|
||
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
|
||
this.form = { id: null, name: '', code: '', asset_class_id: null, 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_id: row.asset_class_id || null, 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_id: this.form.asset_class_id || 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>
|