This commit is contained in:
2026-06-03 13:58:12 -05:00
commit 2f13b8c590
54 changed files with 5136 additions and 0 deletions

249
src/views/AdminView.vue Normal file
View File

@@ -0,0 +1,249 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Users and roles</h2>
<div class="quiet text-body-2">Manage account access, team membership, role assignment, and active status.</div>
</div>
<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>
</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>
</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-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>
</v-card>
<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" />
<v-text-field v-model="localSettings.cors_allowed_domains" label="Allowed CORS domains" />
<v-text-field v-model="localSettings.database_driver" label="Database driver" readonly />
<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>
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Tax rule sets</h2>
<v-list lines="two">
<v-list-item v-for="rule in taxRules" :key="rule.id" prepend-icon="mdi-scale-balance">
<v-list-item-title>{{ rule.name }} · {{ rule.version }}</v-list-item-title>
<v-list-item-subtitle>{{ rule.jurisdiction }} · {{ rule.effective_date }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Workday connector</h2>
<div class="quiet text-body-2">Configure employee, department, and location sync from your Workday tenant.</div>
</div>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-cloud-sync" :loading="workdaySyncing" @click="$emit('sync-workday')">Sync workers</v-btn>
</div>
<div class="form-grid">
<v-text-field v-model="localWorkday.name" label="Connection name" />
<v-text-field v-model="localWorkday.tenant" label="Tenant" />
<v-text-field v-model="localWorkday.base_url" label="Base URL" />
<v-text-field v-model="localWorkday.token_url" label="Token URL" />
<v-text-field v-model="localWorkday.client_id" label="Client ID" />
<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 />
</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>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="workdaySaving" @click="$emit('save-workday', localWorkday)">Save Workday connection</v-btn>
</div>
<v-alert v-if="workdayConnection.last_sync_status" class="mt-3" density="compact" type="info">{{ workdayConnection.last_sync_status }}</v-alert>
</v-card>
<v-dialog v-model="userDialog" max-width="560">
<v-card>
<v-card-title>{{ userForm.id ? 'Edit user' : 'Add user' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<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.status" :items="['active', 'inactive']" label="Status" />
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
</div>
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
{{ roleSummary(userForm.role) }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="userDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="userSaving" @click="saveUser">Save user</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="teamDialog" max-width="460">
<v-card>
<v-card-title>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" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="teamDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="teamSaving" @click="saveTeam">Save team</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
export default {
props: {
roleCapabilities: { type: Array, default: () => [] },
rolePermissions: { type: Object, 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: () => [] },
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'],
data() {
return {
localSettings: { ...this.settings },
localWorkday: { ...this.workdayConnection, client_secret: '' },
teamDialog: false,
teamForm: { name: '', description: '' },
userDialog: false,
userForm: this.blankUser()
};
},
computed: {
teamOptions() {
return this.teams.map((team) => ({ title: team.name, value: team.id }));
}
},
watch: {
settings: {
handler(value) {
this.localSettings = { ...value };
},
deep: true
},
workdayConnection: {
handler(value) {
this.localWorkday = { ...value, client_secret: '' };
},
deep: true
}
},
methods: {
blankUser() {
return {
id: null,
name: '',
email: '',
team_id: null,
role: 'viewer',
status: 'active',
password: ''
};
},
editUser(account) {
this.userForm = {
id: account.id,
name: account.name,
email: account.email,
team_id: account.team_id,
role: account.role,
status: account.status,
password: ''
};
this.userDialog = true;
},
roleSummary(role) {
return (this.rolePermissions[role] || []).join(' · ');
},
saveTeam() {
this.$emit('create-team', { ...this.teamForm });
this.teamDialog = false;
this.teamForm = { name: '', description: '' };
},
saveUser() {
const payload = { ...this.userForm };
if (!payload.password) delete payload.password;
this.$emit(payload.id ? 'update-user' : 'create-user', payload);
this.userDialog = false;
this.userForm = this.blankUser();
}
}
};
</script>

86
src/views/AssetsView.vue Normal file
View File

@@ -0,0 +1,86 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<v-text-field v-model="localFilters.search" label="Search" hide-details prepend-inner-icon="mdi-magnify" style="max-width: 300px" @update:model-value="emitFilters" />
<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-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>
<template #activator="{ props }">
<v-btn v-bind="props" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="format in ['json', 'csv', 'xlsx', 'xml']" :key="format" :title="format.toUpperCase()" @click="$emit('export-assets', format)" />
</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>
</v-card>
</section>
</template>
<script>
export default {
props: {
allSelected: { type: Boolean, default: false },
assets: { type: Array, required: true },
assetFilters: { type: Object, required: true },
currency: { type: Function, required: true },
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'],
data() {
return {
localFilters: { ...this.assetFilters }
};
},
watch: {
assetFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
}
},
methods: {
emitFilters() {
this.$emit('filter-assets', { ...this.localFilters });
}
}
};
</script>

View File

@@ -0,0 +1,168 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Assigned</div>
<div class="metric-value">{{ summary.active_assignments || 0 }}</div>
<div class="metric-subtle">Assets with active custody</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Unassigned</div>
<div class="metric-value">{{ summary.unassigned_assets || 0 }}</div>
<div class="metric-subtle">Need owner/location review</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Employees</div>
<div class="metric-value">{{ employees.length }}</div>
<div class="metric-subtle">Manual or Workday synced</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Locations</div>
<div class="metric-value">{{ summary.assigned_locations || 0 }}</div>
<div class="metric-subtle">Active assignment sites</div>
</v-card>
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Assign asset</h2>
<v-select v-model="assignmentForm.asset_id" :items="assetOptions" item-title="title" item-value="value" label="Asset" />
<v-select v-model="assignmentForm.employee_id" :items="employeeOptions" item-title="title" item-value="value" clearable label="Employee" @update:model-value="applyEmployeeDefaults" />
<v-text-field v-model="assignmentForm.department" label="Department" />
<v-text-field v-model="assignmentForm.location" label="Location" />
<v-textarea v-model="assignmentForm.notes" label="Notes" rows="2" />
<v-btn color="primary" prepend-icon="mdi-account-check" :loading="assignmentSaving" @click="$emit('assign-asset', assignmentForm)">Assign asset</v-btn>
</v-card>
<v-card class="span-7 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Employees</h2>
<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>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Traceability</h2>
<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>
</v-card>
<v-dialog v-model="employeeDialog" max-width="480">
<v-card>
<v-card-title>Add employee</v-card-title>
<v-card-text>
<v-text-field v-model="employeeForm.name" label="Name" />
<v-text-field v-model="employeeForm.email" label="Email" />
<v-text-field v-model="employeeForm.employee_number" label="Employee number" />
<v-text-field v-model="employeeForm.department" label="Department" />
<v-text-field v-model="employeeForm.location" label="Location" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="employeeDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="saveEmployee">Save employee</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
export default {
props: {
assignments: { type: Array, required: true },
assets: { type: Array, required: true },
assignmentSaving: { type: Boolean, default: false },
employees: { type: Array, required: true },
shortDate: { type: Function, required: true },
summary: { type: Object, required: true }
},
emits: ['add-employee', 'assign-asset', 'release-assignment', 'reload'],
data() {
return {
assignmentForm: {
asset_id: null,
employee_id: null,
department: '',
location: '',
notes: ''
},
employeeDialog: false,
employeeForm: {
name: '',
email: '',
employee_number: '',
department: '',
location: ''
}
};
},
computed: {
assetOptions() {
return this.assets.map((asset) => ({
title: `${asset.asset_id} · ${asset.description}`,
value: asset.id
}));
},
employeeOptions() {
return this.employees.map((employee) => ({
title: `${employee.name}${employee.department ? ` · ${employee.department}` : ''}`,
value: employee.id
}));
}
},
methods: {
applyEmployeeDefaults(id) {
const employee = this.employees.find((item) => item.id === id);
if (!employee) return;
this.assignmentForm.department = employee.department || '';
this.assignmentForm.location = employee.location || '';
},
saveEmployee() {
this.$emit('add-employee', { ...this.employeeForm });
this.employeeDialog = false;
this.employeeForm = { name: '', email: '', employee_number: '', department: '', location: '' };
}
}
};
</script>

View File

@@ -0,0 +1,61 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Assets</div>
<div class="metric-value">{{ dashboard.stats.asset_count }}</div>
<div class="metric-subtle">{{ dashboard.stats.in_service_count }} in service</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Capitalized cost</div>
<div class="metric-value">{{ currency(dashboard.stats.total_cost) }}</div>
<div class="metric-subtle">{{ dashboard.byCategory.length }} categories</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Disposed</div>
<div class="metric-value">{{ dashboard.stats.disposed_count }}</div>
<div class="metric-subtle">Tracked gain/loss ready</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Open tasks</div>
<div class="metric-value">{{ dashboard.upcomingTasks.length }}</div>
<div class="metric-subtle">Maintenance and reviews</div>
</v-card>
<v-card class="span-7 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Category value</h2>
</div>
<div class="chart-box">
<Bar :data="categoryChart" :options="chartOptions" />
</div>
</v-card>
<v-card class="span-5 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Activity</h2>
</div>
<v-list lines="two" density="compact">
<v-list-item v-for="log in dashboard.auditTrail" :key="log.id" prepend-icon="mdi-history">
<v-list-item-title>{{ log.action }} {{ log.entity_type }}</v-list-item-title>
<v-list-item-subtitle>{{ log.actor_name || 'System' }} · {{ shortDate(log.created_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
export default {
components: { Bar },
props: {
categoryChart: { type: Object, required: true },
chartOptions: { type: Object, required: true },
currency: { type: Function, required: true },
dashboard: { type: Object, required: true },
shortDate: { type: Function, required: true }
}
};
</script>

46
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,46 @@
<template>
<div class="login-screen">
<v-card class="login-card" elevation="12">
<div class="brand-lockup">
<span class="brand-mark">MA</span>
<span class="brand-name">MixedAssets</span>
</div>
<v-divider />
<v-card-text class="pa-5">
<h1 class="text-h5 font-weight-bold mb-5">Fixed asset command center</h1>
<v-text-field v-model="localForm.email" label="Email" autocomplete="email" prepend-inner-icon="mdi-email-outline" />
<v-text-field
v-model="localForm.password"
label="Password"
type="password"
autocomplete="current-password"
prepend-inner-icon="mdi-lock-outline"
@keyup.enter="$emit('login', localForm)"
/>
<v-alert v-if="error" class="mb-4" density="compact" type="error">{{ error }}</v-alert>
<v-btn block color="primary" :loading="loading" prepend-icon="mdi-login" @click="$emit('login', localForm)">Sign in</v-btn>
</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
props: {
error: { type: String, default: '' },
form: { type: Object, required: true },
loading: { type: Boolean, default: false }
},
emits: ['login'],
data() {
return {
localForm: { ...this.form }
};
},
watch: {
form(value) {
this.localForm = { ...value };
}
}
};
</script>

77
src/views/ReportsView.vue Normal file
View File

@@ -0,0 +1,77 @@
<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>
</v-card>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
export default {
components: { Bar },
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 }
},
emits: ['filter-reports'],
data() {
return {
localFilters: { ...this.reportFilters }
};
},
watch: {
reportFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
}
},
methods: {
emitFilters() {
this.$emit('filter-reports', { ...this.localFilters });
}
}
};
</script>

View File

@@ -0,0 +1,85 @@
<template>
<section class="content-grid">
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Asset template</h2>
<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" />
<v-text-field v-model.number="localForm.defaults.useful_life_months" type="number" label="Useful life months" />
<v-select v-model="localForm.defaults.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Depreciation method" />
<v-divider class="my-4" />
<div class="toolbar-row mb-3">
<h3 class="section-title">Custom fields</h3>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="addField">Add field</v-btn>
</div>
<div v-if="!localForm.custom_fields.length" class="quiet text-body-2 mb-4">No custom fields defined.</div>
<div v-for="(field, index) in localForm.custom_fields" :key="field.local_id" class="custom-field-row">
<v-text-field v-model="field.label" label="Label" @update:model-value="syncFieldKey(field)" />
<v-text-field v-model="field.key" label="Key" />
<v-select v-model="field.type" :items="customFieldTypes" item-title="title" item-value="value" label="Type" />
<v-text-field v-if="field.type !== 'bool'" v-model="field.defaultValue" label="Default" />
<v-switch v-else v-model="field.defaultValue" label="Default" color="primary" density="compact" hide-details />
<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-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>
</v-card>
</section>
</template>
<script>
import { slugFieldKey } from '../utils/assets';
import { clonePlain } from '../utils/clone';
export default {
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 }
},
emits: ['save-template'],
data() {
return {
localForm: clonePlain(this.templateForm)
};
},
watch: {
templateForm: {
handler(value) {
this.localForm = clonePlain(value);
},
deep: true
}
},
methods: {
addField() {
this.localForm.custom_fields.push({
local_id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
key: '',
label: '',
type: 'text',
required: false,
defaultValue: ''
});
},
syncFieldKey(field) {
if (!field.key) field.key = slugFieldKey(field.label);
}
}
};
</script>