1355 lines
53 KiB
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>
|