Updated A LOT
This commit is contained in:
@@ -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