Files
MixedAssets/src/App.vue

1355 lines
53 KiB
Vue

<template>
<fluent-design-system-provider accent-base-color="#2457a6" :base-layer-luminance="fluentLuminance">
<v-app :theme="activeTheme">
<LoginView v-if="!token" :error="error" :form="loginForm" :loading="loading" @login="login" />
<div v-else-if="forcePasswordChange" class="forced-change-screen">
<ChangePasswordCard :token="token" forced standalone @changed="completeForcedChange" @cancel="logout" />
</div>
<div v-else class="app-shell">
<a class="skip-link" href="#main-content">Skip to main content</a>
<v-navigation-drawer class="rail" :width="254" :rail="navCollapsed" :rail-width="76" permanent aria-label="Primary navigation">
<div class="brand-lockup">
<template v-if="!navCollapsed">
<span class="brand-mark">MA</span>
<span class="brand-name">DepreCore</span>
<v-spacer />
<v-btn icon="mdi-chevron-left" size="small" variant="text" title="Collapse menu" aria-label="Collapse navigation menu" @click="toggleNav" />
</template>
<v-btn v-else icon="mdi-menu" size="small" variant="text" class="mx-auto" title="Expand menu" aria-label="Expand navigation menu" @click="toggleNav" />
</div>
<v-divider />
<v-list nav density="compact" class="pa-2">
<template v-for="item in visibleNavItems" :key="item.key">
<v-list-group v-if="item.children && item.children.length" :value="item.key">
<template #activator="{ props }">
<v-list-item
v-bind="props"
:active="isNavActive(item)"
:prepend-icon="item.icon"
:title="item.label"
rounded="sm"
@click="selectNav(item)"
/>
</template>
<v-list-item
v-for="child in item.children"
:key="child.key"
:active="activeView === child.key"
:prepend-icon="child.icon"
:title="child.label"
rounded="sm"
@click="activeView = child.key"
/>
</v-list-group>
<v-list-item
v-else
:active="activeView === item.key"
:prepend-icon="item.icon"
:title="item.label"
rounded="sm"
@click="activeView = item.key"
/>
</template>
</v-list>
<template #append>
<div :class="navCollapsed ? 'pa-2 text-center' : 'pa-4'">
<template v-if="!navCollapsed">
<div class="text-body-2 font-weight-bold">{{ user?.name }}</div>
<div class="text-caption quiet">{{ user?.role }}</div>
<v-btn class="mt-3" block variant="tonal" prepend-icon="mdi-logout" @click="logout">Sign out</v-btn>
</template>
<v-btn v-else icon="mdi-logout" size="small" variant="text" title="Sign out" aria-label="Sign out" @click="logout" />
</div>
</template>
</v-navigation-drawer>
<TopNav
:preferences="profilePreferences"
:saving="preferenceSaving"
:subtitle="currentSubtitle"
:theme-items="themeItems"
:title="currentTitle"
:user="user"
@change-password="changePasswordDialog = true"
@logout="logout"
@open-help="helpDialog = true"
@save-preferences="saveProfilePreferences"
@toggle-nav="toggleNav"
>
<template #actions>
<v-btn v-if="activeView === 'assets' && canEditAssets" color="primary" prepend-icon="mdi-plus" @click="newAsset">New asset</v-btn>
</template>
</TopNav>
<v-main id="main-content" tabindex="-1">
<DashboardView
v-if="activeView === 'dashboard'"
:category-chart="categoryChart"
:chart-options="chartOptions"
:currency="currency"
:dashboard="dashboard"
:short-date="shortDate"
/>
<AssetsView
v-if="activeView === 'assets'"
:all-selected="allSelected"
:assets="assets"
:asset-filters="assetFilters"
:currency="currency"
:selected-asset-ids="selectedAssetIds"
:status-items="statusItems"
@edit-asset="editAsset"
@export-assets="exportAssets"
@filter-assets="filterAssets"
@import-assets="importAssets"
@open-mass-edit="massDialog = true"
@print-labels="printLabels"
@toggle-all="toggleAll"
@toggle-asset="toggleAsset"
/>
<ScanView
v-if="activeView === 'scan'"
:token="token"
@asset-updated="onScanAssetUpdated"
/>
<AlertsView
v-if="activeView === 'alerts'"
:token="token"
:is-admin="user?.role === 'admin'"
/>
<PmView
v-if="activeView === 'pm'"
:token="token"
/>
<PartsView
v-if="activeView === 'parts'"
:token="token"
:can-delete="canDeleteParts"
/>
<ContactsView
v-if="activeView === 'contacts'"
:token="token"
:can-delete="canManageContacts"
/>
<AssignmentsView
v-if="activeView === 'assignments'"
:assignments="assignments"
:assets="assets"
:assignment-saving="assignmentSaving"
:employees="employees"
:short-date="shortDate"
:summary="assignmentSummary"
@add-employee="addEmployee"
@assign-asset="assignAsset"
@release-assignment="releaseAssignment"
@reload="loadAssignments"
/>
<ReportsView
v-if="activeView === 'reports'"
:token="token"
/>
<TaxRulesView
v-if="activeView === 'tax-rules'"
:token="token"
:dark="activeTheme === 'depreCoreDark'"
@updated="reloadTaxRules"
/>
<BooksView
v-if="activeView === 'books'"
:token="token"
:method-items="methodItems"
@updated="loadBookCodes"
/>
<TemplatesView
v-if="activeView === 'templates'"
:category-items="categoryItems"
:custom-field-types="customFieldTypes"
:method-items="methodItems"
:template-form="templateForm"
:templates="templates"
:can-delete="canManageConfig"
@save-template="saveTemplate"
@delete-template="deleteTemplate"
/>
<AdminView
v-if="activeView.startsWith('admin')"
:section="activeView"
:token="token"
:capability-catalog="capabilityCatalog"
:roles="roles"
:short-date="shortDate"
:settings="settings"
:tax-rules="taxRules"
:team-saving="teamSaving"
:team-notice="teamNotice"
:teams="teams"
:user-saving="userSaving"
:users="users"
:workday-connection="workdayConnection"
:workday-saving="workdaySaving"
:workday-syncing="workdaySyncing"
@categories-updated="loadReferences"
@create-team="createTeam"
@update-team="updateTeam"
@delete-team="deleteTeam"
@create-user="createUser"
@create-role="createRole"
@update-role="updateRole"
@delete-role="deleteRole"
@save-settings="saveSettings"
@save-workday="saveWorkdayConnection"
@sync-workday="syncWorkday"
@update-user="updateUser"
@unlock-user="unlockUser"
/>
</v-main>
<AssetDrawer
v-model="assetDrawer"
:asset-form="assetForm"
:asset-adjustments="activeAdjustments"
:asset-disposals="activeDisposals"
:asset-files="activeFiles"
:asset-leases="activeLeases"
:asset-notes="activeNotes"
:asset-photos="activePhotos"
:asset-pm="activePm"
:asset-tasks="activeTasks"
:asset-warranties="activeWarranties"
:book-items="activeBookItems"
:pm-plans="pmPlans"
:pm-defaults="pmDefaults"
:current-user-name="user?.name"
:category-items="categoryItems"
:disposal-preview="disposalPreview"
:drawer-error="drawerError"
:entity-options="entityOptions"
:location-items="locationItems"
:department-items="departmentItems"
:vendor-items="vendorItems"
:category-class-numbers="categoryClassNumbers"
:zone-items="zoneItems"
:zone-details="zoneDetails"
:lease-schedule="leaseSchedule"
:method-items="methodItems"
:saving="saving"
:selected-lease-id="selectedLeaseId"
:selected-template-fields="selectedTemplateFields"
:selected-template-id="selectedTemplateId"
:status-items="statusItems"
:template-options="templateOptions"
@attach-pm="attachPm"
@detach-pm="detachPm"
@complete-pm="completePm"
@log-reading="logReading"
@view-completion="viewCompletion"
@add-adjustment="addAdjustment"
@add-note="addNote"
@add-task="addTask"
@upload-photo="uploadAssetPhoto"
@update-photo="updateAssetPhoto"
@delete-photo="deleteAssetPhoto"
@add-warranty="addWarranty"
@delete-adjustment="deleteAdjustment"
@delete-file="deleteAssetFile"
@delete-lease="deleteLease"
@delete-note="deleteNote"
@delete-task="deleteTask"
@delete-warranty="deleteWarranty"
@download-file="downloadAssetFile"
@load-lease-schedule="loadLeaseSchedule"
@preview-disposal="previewDisposal"
@print-label="printSingleLabel"
@record-disposal="recordDisposal"
@reverse-disposal="reverseDisposal"
@save-asset="saveAsset"
@save-lease="saveLease"
@select-template="applyTemplate"
@toggle-task="toggleTask"
@upload-file="uploadAssetFile"
/>
<MassEditDialog
v-model="massDialog"
:changes="massChanges"
:status-items="statusItems"
@apply="massUpdate"
/>
<LabelSheet v-model="labelDialog" :assets="labelAssets" />
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
<UserManual v-model="helpDialog" />
<v-dialog v-model="changePasswordDialog" max-width="480">
<ChangePasswordCard :token="token" @changed="changePasswordDialog = false" @cancel="changePasswordDialog = false" />
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="5000" location="top end">
{{ snackbar.text }}
</v-snackbar>
</div>
</v-app>
</fluent-design-system-provider>
</template>
<script>
import AdminView from './views/AdminView.vue';
import AlertsView from './views/AlertsView.vue';
import AssignmentsView from './views/AssignmentsView.vue';
import BooksView from './views/BooksView.vue';
import ContactsView from './views/ContactsView.vue';
import AssetsView from './views/AssetsView.vue';
import DashboardView from './views/DashboardView.vue';
import LoginView from './views/LoginView.vue';
import ChangePasswordCard from './components/ChangePasswordCard.vue';
import PartsView from './views/PartsView.vue';
import PmView from './views/PmView.vue';
import ReportsView from './views/ReportsView.vue';
import ScanView from './views/ScanView.vue';
import TaxRulesView from './views/TaxRulesView.vue';
import TemplatesView from './views/TemplatesView.vue';
import AssetDrawer from './components/AssetDrawer.vue';
import LabelSheet from './components/LabelSheet.vue';
import MassEditDialog from './components/MassEditDialog.vue';
import PmCompletionDialog from './components/PmCompletionDialog.vue';
import TopNav from './components/TopNav.vue';
import UserManual from './components/UserManual.vue';
import { apiRequest, loginRequest, saveBlob } from './services/api';
import { blankAsset, defaultValueForField, makeDefaultBooks, mergeBooks, normalizeCustomFields } from './utils/assets';
import { currency, shortDate } from './utils/format';
import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants';
const emptyStats = {
asset_count: 0,
total_cost: 0,
disposed_count: 0,
in_service_count: 0
};
export default {
components: {
AdminView,
AlertsView,
AssignmentsView,
AssetDrawer,
AssetsView,
BooksView,
ChangePasswordCard,
ContactsView,
DashboardView,
LabelSheet,
LoginView,
MassEditDialog,
PartsView,
PmCompletionDialog,
PmView,
ReportsView,
ScanView,
TaxRulesView,
TemplatesView,
TopNav,
UserManual
},
data() {
return {
activeView: 'dashboard',
activeAdjustments: [],
activeDisposals: [],
activeFiles: [],
activeLeases: [],
activeNotes: [],
activePhotos: [],
activePm: [],
activeTasks: [],
activeWarranties: [],
assets: [],
bookCodes: [],
completionDialog: false,
completionId: null,
pmPlans: [],
pmDefaults: { value: 3, unit: 'months' },
assetDrawer: false,
assetFilters: { search: '', status: null },
assetForm: blankAsset(),
assignments: [],
assignmentSaving: false,
assignmentSummary: {},
bookItems,
chartOptions: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}` } } }
},
customFieldTypes,
dashboard: { stats: { ...emptyStats }, byCategory: [], byStatus: [], upcomingTasks: [], auditTrail: [] },
disposalPreview: null,
drawerError: '',
leaseSchedule: null,
selectedLeaseId: null,
employees: [],
entities: [],
error: '',
helpDialog: false,
changePasswordDialog: false,
snackbar: { show: false, text: '', color: 'error' },
labelDialog: false,
labelAssetsOverride: null,
loading: false,
forcePasswordChange: false,
loginForm: { email: 'admin@deprecore.local', password: 'ChangeMe123!' },
massChanges: { status: null, location: '', department: '' },
massDialog: false,
navCollapsed: localStorage.getItem('deprecore.navCollapsed') === 'true',
navItems,
preferenceSaving: false,
profilePreferences: {
theme: localStorage.getItem('deprecore.theme') || 'depreCoreDark',
fontScale: localStorage.getItem('deprecore.fontScale') || 'normal',
accent: localStorage.getItem('deprecore.accent') || '',
contrast: localStorage.getItem('deprecore.contrast') === 'true'
},
references: [],
workLocations: [],
vendorContacts: [],
depreciationZones: [],
userCapabilities: JSON.parse(localStorage.getItem('deprecore.capabilities') || '[]'),
capabilityCatalog: [],
roles: [],
saving: false,
selectedAssetIds: [],
selectedTemplateId: null,
settings: {},
statusItems,
taxRules: [],
teamSaving: false,
teamNotice: '',
teams: [],
templateForm: {
id: null,
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
},
templates: [],
themeItems,
token: localStorage.getItem('deprecore.token') || '',
user: JSON.parse(localStorage.getItem('deprecore.user') || 'null'),
userSaving: false,
users: [],
workdayConnection: {},
workdaySaving: false,
workdaySyncing: false
};
},
computed: {
allSelected() {
return this.assets.length > 0 && this.selectedAssetIds.length === this.assets.length;
},
categoryChart() {
return {
labels: this.dashboard.byCategory.map((row) => row.category),
datasets: [{ data: this.dashboard.byCategory.map((row) => row.value), backgroundColor: '#2457a6' }]
};
},
categoryItems() {
// Admin-managed categories, plus any value already in use so existing assets stay selectable.
const managed = this.references.filter((item) => item.type === 'category').map((item) => item.name);
const inUse = this.assets.map((asset) => asset.category).filter(Boolean);
return [...new Set([...managed, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
locationItems() {
// Work-location contacts, plus any location value already in use so existing assets stay selectable.
const inUse = this.assets.map((asset) => asset.location).filter(Boolean);
return [...new Set([...this.workLocations, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
departmentItems() {
// Admin-managed departments, plus any value already in use so existing assets stay selectable.
const managed = this.references.filter((item) => item.type === 'department').map((item) => item.name);
const inUse = this.assets.map((asset) => asset.department).filter(Boolean);
return [...new Set([...managed, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
categoryClassNumbers() {
// Map of category name -> asset class number, for live display on the asset form.
const map = {};
for (const ref of this.references) {
if (ref.type === 'category' && ref.metadata && ref.metadata.asset_class_number) {
map[ref.name] = ref.metadata.asset_class_number;
}
}
return map;
},
vendorItems() {
// Vendor / supplier contacts, plus any vendor value already in use so existing assets stay selectable.
const inUse = this.assets.map((asset) => asset.vendor).filter(Boolean);
return [...new Set([...this.vendorContacts, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
zoneItems() {
return [{ title: 'None', value: '' }, ...this.depreciationZones.filter((z) => z.enabled).map((z) => ({ title: z.name, value: z.code }))];
},
zoneDetails() {
return Object.fromEntries(this.depreciationZones.map((z) => [z.code, z]));
},
canEditAssets() {
return this.can('assets.edit');
},
canDeleteParts() {
return this.can('parts.manage');
},
canManageContacts() {
return this.can('contacts.manage');
},
canManageConfig() {
return this.can('config.manage');
},
currentSubtitle() {
return {
dashboard: 'Portfolio value, lifecycle activity, and open work',
assets: 'Asset register, imports, exports, and lifecycle updates',
scan: 'Scan a barcode to find and update an asset in the field',
alerts: 'Maintenance, warranty, and lease reminders with email digests',
pm: 'Build reusable preventative maintenance plans with step checklists',
parts: 'Maintenance parts inventory — stock levels, aisle/bin locations, suppliers, and reservations',
assignments: 'Assign assets to people, departments, and locations with full custody history',
contacts: 'Employees, vendors, service providers, contractors, and work locations',
reports: 'Depreciation schedules, monthly expense, and book values',
books: 'Configure books, assign tax rule sets, and view the general ledger',
templates: 'JSON-driven entry defaults and custom fields',
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
admin: 'Users, configuration, and integrations',
'admin-users': 'User accounts, teams, and role permissions',
'admin-security': 'Password rules, account lockout, and complexity policy',
'admin-audit': 'Complete audit trail of all user activity, with export',
'admin-config': 'Application settings, maintenance defaults, and tax rule sets',
'admin-integrations': 'Email, webhooks, ServiceNow, and Workday connectors'
}[this.activeView];
},
currentTitle() {
return {
dashboard: 'Dashboard',
assets: 'Asset register',
scan: 'Scan & update',
alerts: 'Alerts',
pm: 'Preventative maintenance',
parts: 'Parts inventory',
assignments: 'Assignments',
contacts: 'Contacts',
reports: 'Reports',
books: 'Books',
templates: 'Templates',
'tax-rules': 'Tax rules',
admin: 'Configuration',
'admin-users': 'Users & Teams',
'admin-security': 'Password & Security',
'admin-audit': 'Activity & Audit Trail',
'admin-config': 'Application Configuration',
'admin-integrations': 'Integrations'
}[this.activeView];
},
entityOptions() {
return this.entities.map((entity) => ({ title: entity.name, value: entity.id }));
},
activeBookItems() {
return this.bookCodes.length ? this.bookCodes : this.bookItems;
},
labelAssets() {
if (this.labelAssetsOverride) return this.labelAssetsOverride;
if (this.selectedAssetIds.length) return this.assets.filter((asset) => this.selectedAssetIds.includes(asset.id));
return this.assets;
},
activeTheme() {
return this.profilePreferences.theme || 'depreCoreDark';
},
fluentLuminance() {
return darkThemes.includes(this.activeTheme) ? '0.12' : '1';
},
methodItems() {
const methods = this.taxRules[0]?.rules_json?.methods || [];
const fallback = [
{ code: 'straight_line', family: 'GAAP', label: 'Straight-line' },
{ code: 'sum_of_years_digits', family: 'GAAP', label: 'Sum-of-years-digits' },
{ code: 'declining_balance_200', family: 'GAAP', label: '200% declining balance' },
{ code: 'manual', family: 'Manual', label: 'Manual depreciation entry' }
];
return (methods.length ? methods : fallback).map((method) => ({
title: `${method.family} · ${method.label}`,
value: method.code
}));
},
selectedTemplate() {
return this.templates.find((template) => template.id === this.selectedTemplateId) || null;
},
selectedTemplateFields() {
return normalizeCustomFields(this.selectedTemplate?.custom_fields || []);
},
templateOptions() {
return this.templates.map((template) => ({ title: template.name, value: template.id }));
},
visibleNavItems() {
// Each screen maps to the capability/capabilities (any-of) that reveal it; absent = always.
const navCaps = {
assets: ['assets.view'],
scan: ['assets.edit'],
assignments: ['assets.view'],
templates: ['assets.view'],
alerts: ['alerts.manage'],
pm: ['pm.view'],
parts: ['parts.manage'],
contacts: ['contacts.manage'],
reports: ['finance.view'],
books: ['finance.manage'],
'tax-rules': ['finance.manage'],
'admin-users': ['admin.users'],
'admin-security': ['admin.security'],
'admin-audit': ['admin.audit'],
'admin-config': ['admin.settings', 'config.manage'],
'admin-integrations': ['admin.integrations']
};
const visible = (key) => this.canAny(navCaps[key]);
return this.navItems
.map((item) => {
if (!item.children) return visible(item.key) ? item : null;
const children = item.children.filter((child) => visible(child.key));
// Screen-parents (assets, pm) show if the parent or any child is visible;
// group-only parents (financial, admin) show only when a child is visible.
const show = item.group ? children.length > 0 : (visible(item.key) || children.length > 0);
return show ? { ...item, children } : null;
})
.filter(Boolean);
}
},
watch: {
activeView(view) {
if (view.startsWith('admin')) this.loadAdmin();
if (view === 'assignments') this.loadAssignments();
},
visibleNavItems(items) {
const keys = items.flatMap((item) => [item.key, ...(item.children || []).map((child) => child.key)]);
if (!keys.includes(this.activeView)) {
this.activeView = items[0]?.key || 'dashboard';
}
},
labelDialog(open) {
if (!open) this.labelAssetsOverride = null;
},
profilePreferences: {
handler() {
this.applyAppearance();
},
deep: true
},
token() {
// The v-app (and its theme root) only exists once signed in — re-apply then.
this.$nextTick(() => this.applyAppearance());
}
},
mounted() {
this.applyAppearance();
window.addEventListener('deprecore:unauthorized', this.handleUnauthorized);
if (this.token) this.loadInitial();
},
beforeUnmount() {
window.removeEventListener('deprecore:unauthorized', this.handleUnauthorized);
},
methods: {
currency,
shortDate,
// Capability checks driven by the signed-in user's role capabilities ('*' = all).
can(capability) {
return this.userCapabilities.includes('*') || this.userCapabilities.includes(capability);
},
canAny(capabilities) {
if (!capabilities || !capabilities.length) return true;
return capabilities.some((cap) => this.can(cap));
},
setCapabilities(capabilities) {
this.userCapabilities = Array.isArray(capabilities) ? capabilities : [];
localStorage.setItem('deprecore.capabilities', JSON.stringify(this.userCapabilities));
},
// Apply accessibility/appearance preferences to the live DOM: base text size (scales
// the whole UI via the root font-size), a custom accent colour, and a high-contrast boost.
applyAppearance() {
document.documentElement.style.fontSize = fontScaleMap[this.profilePreferences.fontScale] || fontScaleMap.normal;
document.body.classList.toggle('ma-contrast-boost', Boolean(this.profilePreferences.contrast));
this.$nextTick(() => {
const root = document.querySelector('.v-application');
if (!root) return;
const triplet = this.hexToTriplet(this.profilePreferences.accent);
if (triplet) {
// Inline styles win over Vuetify's generated per-theme variables.
root.style.setProperty('--v-theme-primary', triplet);
root.style.setProperty('--v-theme-on-primary', this.readableOn(triplet));
} else {
root.style.removeProperty('--v-theme-primary');
root.style.removeProperty('--v-theme-on-primary');
}
});
},
hexToTriplet(hex) {
const match = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
if (!match) return null;
const int = parseInt(match[1], 16);
return `${(int >> 16) & 255},${(int >> 8) & 255},${int & 255}`;
},
// Pick black or white text for legibility on the chosen accent (WCAG luminance).
readableOn(triplet) {
const [r, g, b] = triplet.split(',').map(Number);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? '0,0,0' : '255,255,255';
},
persistAppearanceLocal() {
localStorage.setItem('deprecore.theme', this.activeTheme);
localStorage.setItem('deprecore.fontScale', this.profilePreferences.fontScale || 'normal');
localStorage.setItem('deprecore.accent', this.profilePreferences.accent || '');
localStorage.setItem('deprecore.contrast', String(Boolean(this.profilePreferences.contrast)));
},
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async addEmployee(employee) {
await this.api('/api/employees', { method: 'POST', body: JSON.stringify(employee) });
await this.loadAssignments();
},
applyTemplate(id) {
this.selectedTemplateId = id || null;
const template = this.templates.find((item) => item.id === id);
if (!template) {
this.assetForm = { ...this.assetForm, template_id: null };
return;
}
const customFields = { ...(this.assetForm.custom_fields || {}) };
for (const field of normalizeCustomFields(template.custom_fields)) {
if (customFields[field.key] === undefined) customFields[field.key] = defaultValueForField(field);
}
const merged = { ...this.assetForm, ...template.defaults, custom_fields: customFields, template_id: template.id };
// For a brand-new asset, realign the book defaults to the template's method/life.
if (!merged.id) merged.books = makeDefaultBooks(merged);
this.assetForm = merged;
},
async assignAsset(form) {
this.assignmentSaving = true;
try {
await this.api('/api/assignments', { method: 'POST', body: JSON.stringify(form) });
await Promise.all([this.loadAssignments(), this.loadAssets(), this.loadDashboard()]);
} finally {
this.assignmentSaving = false;
}
},
async createTeam(team) {
this.teamSaving = true;
this.teamNotice = '';
try {
await this.api('/api/teams', { method: 'POST', body: JSON.stringify(team) });
await this.loadAdmin();
} catch (error) {
this.teamNotice = error.message;
} finally {
this.teamSaving = false;
}
},
async updateTeam(team) {
this.teamSaving = true;
this.teamNotice = '';
try {
await this.api(`/api/teams/${team.id}`, { method: 'PUT', body: JSON.stringify(team) });
await this.loadAdmin();
} catch (error) {
this.teamNotice = error.message;
} finally {
this.teamSaving = false;
}
},
async deleteTeam(id) {
this.teamNotice = '';
try {
await this.api(`/api/teams/${id}`, { method: 'DELETE' });
await this.loadAdmin();
} catch (error) {
this.teamNotice = error.message;
}
},
notify(text, color = 'error') {
this.snackbar = { show: true, text, color };
},
async createUser(user) {
this.userSaving = true;
try {
await this.api('/api/users', { method: 'POST', body: JSON.stringify(user) });
await this.loadAdmin();
this.notify('User created.', 'success');
} catch (error) {
this.notify(error.message);
} finally {
this.userSaving = false;
}
},
async unlockUser(account) {
try {
await this.api(`/api/users/${account.id}/unlock`, { method: 'POST' });
await this.loadAdmin();
this.notify(`${account.name} unlocked.`, 'success');
} catch (error) {
this.notify(error.message);
}
},
async editAsset(asset) {
this.drawerError = '';
this.disposalPreview = null;
this.leaseSchedule = null;
this.selectedLeaseId = null;
this.loadPmContext();
this.loadReferences(); this.loadDepreciationZones();
this.loadContactOptions();
await this.loadBookCodes();
await this.refreshActiveAsset(asset.id, true);
this.assetDrawer = true;
},
async refreshActiveAsset(id, syncForm = false) {
const data = await (await this.api(`/api/assets/${id}`)).json();
const asset = data.asset;
this.activeFiles = asset.files || [];
this.activeWarranties = asset.warranties || [];
this.activeTasks = asset.tasks || [];
this.activeNotes = asset.notes_log || [];
this.activePhotos = asset.photos || [];
this.activeDisposals = asset.disposals || [];
this.activeAdjustments = asset.adjustments || [];
this.activeLeases = asset.leases || [];
this.activePm = asset.pm || [];
if (syncForm) {
this.assetForm = {
...blankAsset(),
...asset,
custom_fields: asset.custom_fields || {},
books: mergeBooks(asset.books || [], this.bookCodes, asset)
};
this.selectedTemplateId = asset.template_id || null;
}
return asset;
},
clearActiveCollections() {
this.activeFiles = [];
this.activeWarranties = [];
this.activeTasks = [];
this.activeNotes = [];
this.activePhotos = [];
this.activeDisposals = [];
this.activeAdjustments = [];
this.activeLeases = [];
this.activePm = [];
this.disposalPreview = null;
this.leaseSchedule = null;
this.selectedLeaseId = null;
},
async previewDisposal(form) {
const data = await (await this.api(`/api/assets/${this.assetForm.id}/disposal/preview`, {
method: 'POST',
body: JSON.stringify(form)
})).json();
this.disposalPreview = data.preview;
},
async recordDisposal(form) {
await this.api(`/api/assets/${this.assetForm.id}/disposals`, { method: 'POST', body: JSON.stringify(form) });
this.disposalPreview = null;
await this.refreshActiveAsset(this.assetForm.id, true);
await this.loadAssets();
await this.loadDashboard();
},
async reverseDisposal(disposalId) {
await this.api(`/api/assets/${this.assetForm.id}/disposals/${disposalId}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id, true);
await this.loadAssets();
await this.loadDashboard();
},
async addAdjustment(form) {
await this.api(`/api/assets/${this.assetForm.id}/adjustments`, { method: 'POST', body: JSON.stringify(form) });
await this.refreshActiveAsset(this.assetForm.id);
},
async deleteAdjustment(id) {
await this.api(`/api/assets/${this.assetForm.id}/adjustments/${id}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id);
},
async saveLease(form) {
await this.api('/api/leases', { method: 'POST', body: JSON.stringify({ ...form, asset_id: this.assetForm.id }) });
await this.refreshActiveAsset(this.assetForm.id);
},
async deleteLease(id) {
await this.api(`/api/leases/${id}`, { method: 'DELETE' });
if (this.selectedLeaseId === id) {
this.leaseSchedule = null;
this.selectedLeaseId = null;
}
await this.refreshActiveAsset(this.assetForm.id);
},
async loadLeaseSchedule(leaseId) {
const data = await (await this.api(`/api/leases/${leaseId}/schedule`)).json();
this.selectedLeaseId = leaseId;
this.leaseSchedule = data.schedule;
},
async uploadAssetFile(file) {
const formData = new FormData();
formData.append('file', file);
await this.api(`/api/assets/${this.assetForm.id}/files`, { method: 'POST', body: formData });
await this.refreshActiveAsset(this.assetForm.id);
},
async downloadAssetFile(file) {
const blob = await (await this.api(`/api/assets/${this.assetForm.id}/files/${file.id}`)).blob();
saveBlob(blob, file.file_name);
},
async deleteAssetFile(fileId) {
await this.api(`/api/assets/${this.assetForm.id}/files/${fileId}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id);
},
async uploadAssetPhoto(photo) {
await this.api(`/api/assets/${this.assetForm.id}/photos`, { method: 'POST', body: JSON.stringify(photo) });
await this.refreshActiveAsset(this.assetForm.id);
},
async updateAssetPhoto({ id, caption }) {
await this.api(`/api/assets/${this.assetForm.id}/photos/${id}`, { method: 'PUT', body: JSON.stringify({ caption }) });
await this.refreshActiveAsset(this.assetForm.id);
},
async deleteAssetPhoto(id) {
await this.api(`/api/assets/${this.assetForm.id}/photos/${id}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id);
},
async addWarranty(warranty) {
await this.api(`/api/assets/${this.assetForm.id}/warranties`, { method: 'POST', body: JSON.stringify(warranty) });
await this.refreshActiveAsset(this.assetForm.id);
},
async deleteWarranty(id) {
await this.api(`/api/assets/${this.assetForm.id}/warranties/${id}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id);
},
async addTask(task) {
await this.api(`/api/assets/${this.assetForm.id}/tasks`, { method: 'POST', body: JSON.stringify(task) });
await this.refreshActiveAsset(this.assetForm.id);
await this.loadDashboard();
},
async toggleTask(task) {
const status = task.status === 'complete' ? 'open' : 'complete';
await this.api(`/api/assets/${this.assetForm.id}/tasks/${task.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
await this.refreshActiveAsset(this.assetForm.id);
await this.loadDashboard();
},
async deleteTask(id) {
await this.api(`/api/assets/${this.assetForm.id}/tasks/${id}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id);
await this.loadDashboard();
},
async addNote(body) {
await this.api(`/api/assets/${this.assetForm.id}/notes`, { method: 'POST', body: JSON.stringify({ body }) });
await this.refreshActiveAsset(this.assetForm.id);
},
async deleteNote(id) {
await this.api(`/api/assets/${this.assetForm.id}/notes/${id}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id);
},
async attachPm(form) {
await this.api(`/api/assets/${this.assetForm.id}/pm`, { method: 'POST', body: JSON.stringify(form) });
await this.refreshActiveAsset(this.assetForm.id);
await this.loadDashboard();
},
async detachPm(apId) {
await this.api(`/api/assets/${this.assetForm.id}/pm/${apId}`, { method: 'DELETE' });
await this.refreshActiveAsset(this.assetForm.id);
},
async completePm(payload) {
const { apId, ...body } = payload;
await this.api(`/api/assets/${this.assetForm.id}/pm/${apId}/complete`, {
method: 'POST',
body: JSON.stringify(body)
});
await this.refreshActiveAsset(this.assetForm.id);
await this.loadDashboard();
},
async logReading(payload) {
const { apId, ...body } = payload;
try {
await this.api(`/api/assets/${this.assetForm.id}/pm/${apId}/readings`, {
method: 'POST',
body: JSON.stringify(body)
});
await this.refreshActiveAsset(this.assetForm.id);
this.notify('Reading logged.', 'success');
} catch (error) {
this.notify(error.message);
}
},
viewCompletion(id) {
this.completionId = id;
this.completionDialog = true;
},
async loadBookCodes() {
try {
const data = await (await this.api('/api/books')).json();
this.bookCodes = (data.books || [])
.filter((book) => book.enabled && book.book_type !== 'maintenance')
.map((book) => book.code);
} catch {
// keep the constant fallback
}
},
async loadPmContext() {
const [plans, settings] = await Promise.all([
this.api('/api/pm-plans').then((r) => r.json()).catch(() => ({ plans: [] })),
this.api('/api/pm-settings').then((r) => r.json()).catch(() => ({ settings: {} }))
]);
this.pmPlans = plans.plans || [];
if (settings.settings) {
this.pmDefaults = {
value: settings.settings.pm_default_frequency_value || 3,
unit: settings.settings.pm_default_frequency_unit || 'months'
};
}
},
async exportAssets(format) {
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
saveBlob(blob, `deprecore-assets.${format}`);
},
async loadReferences() {
const data = await (await this.api('/api/references')).json();
this.references = data.references;
},
async loadDepreciationZones() {
try {
const data = await (await this.api('/api/depreciation-zones')).json();
this.depreciationZones = data.zones || [];
} catch {
this.depreciationZones = [];
}
},
async loadContactOptions() {
try {
const data = await (await this.api('/api/contacts')).json();
const contacts = data.contacts || [];
this.workLocations = contacts.filter((c) => c.type === 'work_location').map((c) => c.display_name).filter(Boolean);
this.vendorContacts = contacts
.filter((c) => ['vendor', 'service_provider', 'contractor'].includes(c.type))
.map((c) => c.display_name)
.filter(Boolean);
} catch {
this.workLocations = [];
this.vendorContacts = [];
}
},
filterAssets(filters) {
this.assetFilters = filters;
this.loadAssets();
},
async importAssets(event) {
const [file] = event.target.files || [];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
await this.api('/api/import/assets', { method: 'POST', body: formData });
event.target.value = '';
await this.loadAssets();
await this.loadDashboard();
},
async loadAdmin() {
const [settings, rules, workday, users, teams, accessControl] = await Promise.all([
this.api('/api/settings').then((r) => r.json()).catch(() => ({ settings: {} })),
this.api('/api/tax-rules').then((r) => r.json()),
this.api('/api/workday/connection').then((r) => r.json()).catch(() => ({ connection: {} })),
this.api('/api/users').then((r) => r.json()).catch(() => ({ users: [] })),
this.api('/api/teams').then((r) => r.json()).catch(() => ({ teams: [] })),
this.api('/api/access-control').then((r) => r.json()).catch(() => ({ roles: [], capabilities: [] }))
]);
this.settings = settings.settings;
this.taxRules = rules.ruleSets;
this.workdayConnection = workday.connection || {};
this.users = users.users || [];
this.teams = teams.teams || [];
this.roles = accessControl.roles || [];
this.capabilityCatalog = accessControl.capabilities || [];
},
async createRole(role) {
await this.api('/api/roles', { method: 'POST', body: JSON.stringify(role) });
await this.loadAdmin();
},
async updateRole(role) {
await this.api(`/api/roles/${role.key}`, { method: 'PUT', body: JSON.stringify(role) });
await this.loadAdmin();
await this.loadProfile(); // capabilities may have changed for the current user's role
},
async deleteRole(key) {
await this.api(`/api/roles/${key}`, { method: 'DELETE' });
await this.loadAdmin();
},
async loadAssets() {
const params = new URLSearchParams();
if (this.assetFilters.search) params.set('search', this.assetFilters.search);
if (this.assetFilters.status) params.set('status', this.assetFilters.status);
const data = await (await this.api(`/api/assets?${params.toString()}`)).json();
this.assets = data.assets;
this.selectedAssetIds = this.selectedAssetIds.filter((id) => this.assets.some((asset) => asset.id === id));
},
async loadDashboard() {
this.dashboard = await (await this.api('/api/dashboard')).json();
},
async loadInitial() {
await Promise.all([
this.loadProfile(),
this.loadDashboard(),
this.loadAssets(),
this.loadAssignments(),
this.api('/api/entities').then((r) => r.json()).then((data) => { this.entities = data.entities; }),
this.api('/api/references').then((r) => r.json()).then((data) => { this.references = data.references; }),
this.api('/api/templates').then((r) => r.json()).then((data) => { this.templates = data.templates; }),
this.api('/api/tax-rules').then((r) => r.json()).then((data) => { this.taxRules = data.ruleSets; }),
this.loadPmContext(),
this.loadBookCodes(),
this.loadContactOptions(),
this.loadDepreciationZones()
]);
},
async loadAssignments() {
const [employees, assignments, workday] = await Promise.all([
this.api('/api/employees').then((r) => r.json()),
this.api('/api/assignments').then((r) => r.json()),
this.api('/api/workday/connection').then((r) => r.json()).catch(() => ({ connection: {} }))
]);
this.employees = employees.employees;
this.assignments = assignments.assignments;
this.assignmentSummary = assignments.summary;
this.workdayConnection = workday.connection || {};
},
async loadProfile() {
const profile = await (await this.api('/api/profile')).json();
this.user = profile.user;
this.profilePreferences = { theme: 'depreCoreDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
this.setCapabilities(profile.capabilities);
localStorage.setItem('deprecore.user', JSON.stringify(profile.user));
this.persistAppearanceLocal();
},
async login(form) {
this.loading = true;
this.error = '';
try {
const data = await loginRequest(form);
this.loginForm = { ...form };
this.token = data.token;
this.user = data.user;
this.setCapabilities(data.capabilities);
localStorage.setItem('deprecore.token', data.token);
localStorage.setItem('deprecore.user', JSON.stringify(data.user));
// A forced/expired password gates the app behind the change-password step (token is held
// so the change endpoint is callable, but app data isn't loaded until the change succeeds).
if (data.mustChangePassword) {
this.forcePasswordChange = true;
} else {
await this.loadInitial();
}
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async completeForcedChange() {
this.forcePasswordChange = false;
this.error = '';
this.loginForm = { ...this.loginForm, password: '' };
await this.loadInitial();
},
logout() {
this.token = '';
this.user = null;
this.forcePasswordChange = false;
this.userCapabilities = [];
localStorage.removeItem('deprecore.token');
localStorage.removeItem('deprecore.user');
localStorage.removeItem('deprecore.capabilities');
},
// Triggered when any API call reports an invalid/expired token: return to the login screen.
handleUnauthorized() {
if (!this.token) return; // already signed out
this.logout();
this.error = 'Your session has expired. Please sign in again.';
},
async massUpdate(changesInput) {
const changes = Object.fromEntries(Object.entries(changesInput).filter(([, value]) => value !== '' && value !== null));
await this.api('/api/assets/mass-update', { method: 'POST', body: JSON.stringify({ ids: this.selectedAssetIds, changes }) });
this.massDialog = false;
this.massChanges = { status: null, location: '', department: '' };
await this.loadAssets();
await this.loadDashboard();
},
toggleNav() {
this.navCollapsed = !this.navCollapsed;
localStorage.setItem('deprecore.navCollapsed', String(this.navCollapsed));
},
// Group-only items (no own screen) just expand; real screens navigate.
selectNav(item) {
if (!item.group) this.activeView = item.key;
},
isNavActive(item) {
if (this.activeView === item.key) return true;
return (item.children || []).some((child) => child.key === this.activeView);
},
async reloadTaxRules() {
const data = await (await this.api('/api/tax-rules')).json();
this.taxRules = data.ruleSets;
},
printLabels() {
this.labelAssetsOverride = null;
this.labelDialog = true;
},
printSingleLabel(asset) {
this.labelAssetsOverride = [asset];
this.labelDialog = true;
},
async onScanAssetUpdated() {
await Promise.all([this.loadAssets(), this.loadDashboard()]);
},
async newAsset() {
await this.loadBookCodes();
this.assetForm = blankAsset();
this.assetForm.entity_id = this.entities[0]?.id || null;
this.assetForm.books = makeDefaultBooks(this.assetForm, this.bookCodes);
this.selectedTemplateId = null;
this.drawerError = '';
this.clearActiveCollections();
this.loadPmContext();
this.loadReferences(); this.loadDepreciationZones();
this.loadContactOptions();
this.assetDrawer = true;
},
async saveAsset(assetInput) {
this.saving = true;
this.drawerError = '';
try {
const books = (assetInput.books && assetInput.books.length ? assetInput.books : makeDefaultBooks(assetInput))
.map((book) => ({
...book,
cost: book.cost === '' || book.cost === null || book.cost === undefined ? null : Number(book.cost)
}));
const payload = { ...assetInput, books };
const method = assetInput.id ? 'PUT' : 'POST';
const url = assetInput.id ? `/api/assets/${assetInput.id}` : '/api/assets';
const saved = await (await this.api(url, { method, body: JSON.stringify(payload) })).json();
await this.loadAssets();
await this.loadDashboard();
if (!assetInput.id && saved.asset) {
// Keep the drawer open on the freshly created asset so files, warranties,
// tasks, notes, disposals, and leases can be attached without reopening.
await this.refreshActiveAsset(saved.asset.id, true);
} else {
this.assetDrawer = false;
}
} catch (error) {
this.drawerError = error.message;
} finally {
this.saving = false;
}
},
async saveSettings(settings) {
await this.api('/api/settings', { method: 'PUT', body: JSON.stringify({ settings }) });
await this.loadAdmin();
},
async releaseAssignment(assignment) {
await this.api(`/api/assignments/${assignment.id}/release`, {
method: 'PUT',
body: JSON.stringify({ release_reason: 'Released from UI' })
});
await Promise.all([this.loadAssignments(), this.loadAssets()]);
},
async saveProfilePreferences(preferences, options = { persist: true }) {
this.profilePreferences = { ...this.profilePreferences, ...(preferences || {}) };
this.persistAppearanceLocal();
if (!options.persist) return;
this.preferenceSaving = true;
try {
const profile = await (await this.api('/api/profile', {
method: 'PUT',
body: JSON.stringify({ preferences: this.profilePreferences })
})).json();
this.user = profile.user;
this.profilePreferences = { theme: 'depreCoreDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
localStorage.setItem('deprecore.user', JSON.stringify(profile.user));
this.persistAppearanceLocal();
} finally {
this.preferenceSaving = false;
}
},
async saveWorkdayConnection(connection) {
this.workdaySaving = true;
try {
const body = { ...connection };
if (!body.client_secret) delete body.client_secret;
await this.api('/api/workday/connection', { method: 'PUT', body: JSON.stringify(body) });
await this.loadAssignments();
} finally {
this.workdaySaving = false;
}
},
async saveTemplate(form) {
const payload = {
...form,
custom_fields: normalizeCustomFields(form.custom_fields)
};
const method = form.id ? 'PUT' : 'POST';
const url = form.id ? `/api/templates/${form.id}` : '/api/templates';
await this.api(url, { method, body: JSON.stringify(payload) });
await this.reloadTemplates();
this.resetTemplateForm();
},
async deleteTemplate(id) {
await this.api(`/api/templates/${id}`, { method: 'DELETE' });
await this.reloadTemplates();
if (this.templateForm.id === id) this.resetTemplateForm();
},
async reloadTemplates() {
const data = await (await this.api('/api/templates')).json();
this.templates = data.templates;
},
resetTemplateForm() {
this.templateForm = {
id: null,
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
};
},
async syncWorkday() {
this.workdaySyncing = true;
try {
await this.api('/api/workday/sync-workers', { method: 'POST', body: JSON.stringify({}) });
await this.loadAssignments();
} finally {
this.workdaySyncing = false;
}
},
async updateUser(user) {
this.userSaving = true;
try {
const body = { ...user };
if (!body.password) delete body.password;
await this.api(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(body) });
await this.loadAdmin();
this.notify('User updated.', 'success');
} catch (error) {
this.notify(error.message);
} finally {
this.userSaving = false;
}
},
toggleAll(value) {
this.selectedAssetIds = value ? this.assets.map((asset) => asset.id) : [];
},
toggleAsset(id) {
this.selectedAssetIds = this.selectedAssetIds.includes(id)
? this.selectedAssetIds.filter((assetId) => assetId !== id)
: [...this.selectedAssetIds, id];
}
}
};
</script>