Files
MixedAssets/src/components/CategorySettings.vue

285 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Asset categories</h2>
<p class="quiet text-body-2">
Manage the categories offered in the asset forms Category dropdown. Renaming a category updates every asset that uses it.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New category</v-btn>
</div>
<DataTable
:columns="columns"
:rows="categories"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter categories"
empty-text="No categories yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
<template #cell-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>