Password Policy/App rename/PM Scheduling Improvements

This commit is contained in:
pucketm2016 committed 2026-06-06 02:32:47 -05:00
1 parent dfd999b6a9
commit f024286b5e
59 files changed
+3814 -299

No files matched your search

+101 -31
View File
@@ -3,13 +3,17 @@
<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">MixedAssets</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>
@@ -68,6 +72,7 @@
:theme-items="themeItems"
:title="currentTitle"
:user="user"
@change-password="changePasswordDialog = true"
@logout="logout"
@open-help="helpDialog = true"
@save-preferences="saveProfilePreferences"
@@ -148,7 +153,7 @@
<TaxRulesView
v-if="activeView === 'tax-rules'"
:token="token"
:dark="activeTheme === 'mixedAssetsDark'"
:dark="activeTheme === 'depreCoreDark'"
@updated="reloadTaxRules"
/>
<BooksView
@@ -197,6 +202,7 @@
@save-workday="saveWorkdayConnection"
@sync-workday="syncWorkday"
@update-user="updateUser"
@unlock-user="unlockUser"
/>
</v-main>
@@ -237,6 +243,7 @@
@attach-pm="attachPm"
@detach-pm="detachPm"
@complete-pm="completePm"
@log-reading="logReading"
@view-completion="viewCompletion"
@add-adjustment="addAdjustment"
@add-note="addNote"
@@ -276,6 +283,14 @@
<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>
@@ -290,6 +305,7 @@ 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';
@@ -322,6 +338,7 @@ export default {
AssetDrawer,
AssetsView,
BooksView,
ChangePasswordCard,
ContactsView,
DashboardView,
LabelSheet,
@@ -378,26 +395,29 @@ export default {
entities: [],
error: '',
helpDialog: false,
changePasswordDialog: false,
snackbar: { show: false, text: '', color: 'error' },
labelDialog: false,
labelAssetsOverride: null,
loading: false,
loginForm: { email: 'admin@mixedassets.local', password: 'ChangeMe123!' },
forcePasswordChange: false,
loginForm: { email: 'admin@deprecore.local', password: 'ChangeMe123!' },
massChanges: { status: null, location: '', department: '' },
massDialog: false,
navCollapsed: localStorage.getItem('mixedassets.navCollapsed') === 'true',
navCollapsed: localStorage.getItem('deprecore.navCollapsed') === 'true',
navItems,
preferenceSaving: false,
profilePreferences: {
theme: localStorage.getItem('mixedassets.theme') || 'mixedAssetsDark',
fontScale: localStorage.getItem('mixedassets.fontScale') || 'normal',
accent: localStorage.getItem('mixedassets.accent') || '',
contrast: localStorage.getItem('mixedassets.contrast') === 'true'
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('mixedassets.capabilities') || '[]'),
userCapabilities: JSON.parse(localStorage.getItem('deprecore.capabilities') || '[]'),
capabilityCatalog: [],
roles: [],
saving: false,
@@ -418,8 +438,8 @@ export default {
},
templates: [],
themeItems,
token: localStorage.getItem('mixedassets.token') || '',
user: JSON.parse(localStorage.getItem('mixedassets.user') || 'null'),
token: localStorage.getItem('deprecore.token') || '',
user: JSON.parse(localStorage.getItem('deprecore.user') || 'null'),
userSaving: false,
users: [],
workdayConnection: {},
@@ -503,6 +523,8 @@ export default {
'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];
@@ -523,6 +545,8 @@ export default {
'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];
@@ -539,7 +563,7 @@ export default {
return this.assets;
},
activeTheme() {
return this.profilePreferences.theme || 'mixedAssetsDark';
return this.profilePreferences.theme || 'depreCoreDark';
},
fluentLuminance() {
return darkThemes.includes(this.activeTheme) ? '0.12' : '1';
@@ -581,6 +605,8 @@ export default {
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']
};
@@ -624,11 +650,11 @@ export default {
},
mounted() {
this.applyAppearance();
window.addEventListener('mixedassets:unauthorized', this.handleUnauthorized);
window.addEventListener('deprecore:unauthorized', this.handleUnauthorized);
if (this.token) this.loadInitial();
},
beforeUnmount() {
window.removeEventListener('mixedassets:unauthorized', this.handleUnauthorized);
window.removeEventListener('deprecore:unauthorized', this.handleUnauthorized);
},
methods: {
currency,
@@ -643,7 +669,7 @@ export default {
},
setCapabilities(capabilities) {
this.userCapabilities = Array.isArray(capabilities) ? capabilities : [];
localStorage.setItem('mixedassets.capabilities', JSON.stringify(this.userCapabilities));
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.
@@ -677,10 +703,10 @@ export default {
return lum > 0.6 ? '0,0,0' : '255,255,255';
},
persistAppearanceLocal() {
localStorage.setItem('mixedassets.theme', this.activeTheme);
localStorage.setItem('mixedassets.fontScale', this.profilePreferences.fontScale || 'normal');
localStorage.setItem('mixedassets.accent', this.profilePreferences.accent || '');
localStorage.setItem('mixedassets.contrast', String(Boolean(this.profilePreferences.contrast)));
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);
@@ -747,15 +773,30 @@ export default {
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;
@@ -926,6 +967,19 @@ export default {
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;
@@ -955,7 +1009,7 @@ export default {
},
async exportAssets(format) {
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
saveBlob(blob, `mixedassets-assets.${format}`);
saveBlob(blob, `deprecore-assets.${format}`);
},
async loadReferences() {
const data = await (await this.api('/api/references')).json();
@@ -1068,9 +1122,9 @@ export default {
async loadProfile() {
const profile = await (await this.api('/api/profile')).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
this.profilePreferences = { theme: 'depreCoreDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
this.setCapabilities(profile.capabilities);
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
localStorage.setItem('deprecore.user', JSON.stringify(profile.user));
this.persistAppearanceLocal();
},
async login(form) {
@@ -1082,22 +1136,35 @@ export default {
this.token = data.token;
this.user = data.user;
this.setCapabilities(data.capabilities);
localStorage.setItem('mixedassets.token', data.token);
localStorage.setItem('mixedassets.user', JSON.stringify(data.user));
await this.loadInitial();
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('mixedassets.token');
localStorage.removeItem('mixedassets.user');
localStorage.removeItem('mixedassets.capabilities');
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() {
@@ -1115,7 +1182,7 @@ export default {
},
toggleNav() {
this.navCollapsed = !this.navCollapsed;
localStorage.setItem('mixedassets.navCollapsed', String(this.navCollapsed));
localStorage.setItem('deprecore.navCollapsed', String(this.navCollapsed));
},
// Group-only items (no own screen) just expand; real screens navigate.
selectNav(item) {
@@ -1204,8 +1271,8 @@ export default {
body: JSON.stringify({ preferences: this.profilePreferences })
})).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
localStorage.setItem('mixedassets.user', JSON.stringify(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;
@@ -1267,6 +1334,9 @@ export default {
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;
}
+95 -3
View File
@@ -424,12 +424,22 @@
<v-list-item-title>{{ pm.plan_name || 'Maintenance' }} · every {{ pm.frequency_value }} {{ unitLabel(pm.frequency_unit) }}</v-list-item-title>
<v-list-item-subtitle>
<template v-if="pm.active">
Next due {{ pm.next_due_date || '—' }}<template v-if="pm.last_completed_date"> · last done {{ pm.last_completed_date }}</template>
Next due {{ pm.next_due_date || '—' }}
<v-chip v-if="dueDriver(pm)" size="x-small" variant="tonal" :color="dueDriver(pm).color" class="ml-1">
<v-icon :icon="dueDriver(pm).icon" size="12" start /> {{ dueDriver(pm).label }}
</v-chip>
<template v-if="pm.last_completed_date"> · last done {{ pm.last_completed_date }}</template>
</template>
<template v-else>Schedule complete (past {{ pm.end_date }})</template>
<template v-if="pm.total_cost"> · spent {{ currency(pm.total_cost) }}</template>
<div v-for="m in pm.meters" :key="m.id" class="text-caption">
{{ m.label }}: {{ m.last_reading != null ? Math.round(m.last_reading) : '—' }}<template v-if="m.due_at_reading"> / {{ Math.round(m.due_at_reading) }}</template>
<span v-if="m.unit"> {{ m.unit }}</span>
<span v-if="m.projected_usage_due_date" class="quiet"> · projected {{ m.projected_usage_due_date }}</span>
</div>
</v-list-item-subtitle>
<template #append>
<v-btn v-if="pm.active && pm.meters && pm.meters.length" size="small" variant="text" @click="openLogReading(pm)">Log reading</v-btn>
<v-btn v-if="pm.active" size="small" variant="text" color="primary" @click="openComplete(pm)">Complete</v-btn>
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('detach-pm', pm.id)" />
</template>
@@ -522,6 +532,19 @@
<v-text-field v-model.number="completeCost" type="number" prefix="$" label="Service cost" density="compact" hide-details class="mt-3" />
<template v-if="completeMeters.length">
<v-divider class="my-3" />
<h3 class="section-title mb-1">Usage meter readings</h3>
<p class="quiet text-caption mb-2">Record the current reading; the next service threshold resets from here.</p>
<div v-for="(m, i) in completeMeters" :key="`m${i}`" class="d-flex align-center mb-2" style="gap:10px">
<div class="text-body-2" style="min-width:180px">
{{ m.label }}<span v-if="m.unit" class="quiet"> ({{ m.unit }})</span>
<div v-if="m.last_reading != null" class="quiet text-caption">last {{ Math.round(m.last_reading) }}<template v-if="m.due_at_reading"> · due at {{ Math.round(m.due_at_reading) }}</template></div>
</div>
<v-text-field v-model.number="m.reading" type="number" density="compact" hide-details placeholder="Current reading" :suffix="m.unit || ''" />
</div>
</template>
<v-divider class="my-3" />
<div class="toolbar-row mb-1">
<h3 class="section-title">Completion notes</h3>
@@ -559,6 +582,32 @@
</v-card>
</v-dialog>
<!-- Quick usage-meter reading (recomputes the schedule without completing a service) -->
<v-dialog v-model="logDialog" max-width="420">
<v-card v-if="logTarget">
<v-card-title>Log usage reading</v-card-title>
<v-card-text>
<div class="quiet text-body-2 mb-3">{{ logTarget.plan_name || 'Maintenance' }}</div>
<v-select
v-if="(logTarget.meters || []).length > 1"
v-model="logForm.asset_pm_meter_id"
:items="(logTarget.meters || []).map((m) => ({ title: m.label, value: m.id }))"
item-title="title"
item-value="value"
label="Meter"
density="compact"
/>
<v-text-field v-model.number="logForm.reading" type="number" label="Current reading" density="compact" />
<v-text-field v-model="logForm.reading_date" type="date" label="Reading date" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="logDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-check" :disabled="logForm.reading === '' || logForm.reading == null || !logForm.asset_pm_meter_id" @click="submitLogReading">Save reading</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Prompt shown when depreciation-critical fields change on an already-depreciated asset -->
<v-dialog v-model="applyDialog" max-width="560">
<v-card>
@@ -691,7 +740,7 @@ export default {
'add-adjustment', 'delete-adjustment',
'save-lease', 'delete-lease', 'load-lease-schedule',
'print-label',
'attach-pm', 'detach-pm', 'complete-pm', 'view-completion'
'attach-pm', 'detach-pm', 'complete-pm', 'log-reading', 'view-completion'
],
data() {
return {
@@ -722,6 +771,10 @@ export default {
completeRatings: { safety: 0, physical: 0, operating: 0 },
completeSignature: '',
completePhotos: [],
completeMeters: [],
logDialog: false,
logTarget: null,
logForm: { asset_pm_meter_id: null, reading: '', reading_date: new Date().toISOString().slice(0, 10) },
ratingQuestions: [
{ key: 'safety', label: 'Safety condition / Is it safe?' },
{ key: 'physical', label: 'Physical condition / appearance' },
@@ -926,6 +979,19 @@ export default {
unitLabel(unit) {
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
},
// Which signal drove the next-due date (only meaningful once usage/lifespan are in play).
dueDriver(pm) {
const map = {
usage: { label: 'usage', icon: 'mdi-gauge', color: 'info' },
lifespan: { label: 'age', icon: 'mdi-chart-bell-curve-cumulative', color: 'warning' },
calendar: { label: 'date', icon: 'mdi-calendar', color: undefined }
};
// Only badge when a dynamic signal is active (meters present or lifespan compression applied).
const hasMeters = pm.meters && pm.meters.length;
const compressed = pm.signals && pm.signals.factor != null && pm.signals.factor < 1;
if (!hasMeters && !compressed) return null;
return map[pm.due_driver] || null;
},
resetPmForm() {
this.pmForm = {
plan_id: null,
@@ -956,8 +1022,31 @@ export default {
this.completeRatings = { safety: 0, physical: 0, operating: 0 };
this.completeSignature = '';
this.completePhotos = [];
this.completeMeters = (pm.meters || []).map((m) => ({
asset_pm_meter_id: m.id, label: m.label, unit: m.unit,
last_reading: m.last_reading, due_at_reading: m.due_at_reading, reading: ''
}));
this.completeDialog = true;
},
openLogReading(pm) {
this.logTarget = pm;
this.logForm = {
asset_pm_meter_id: (pm.meters && pm.meters[0]) ? pm.meters[0].id : null,
reading: '',
reading_date: new Date().toISOString().slice(0, 10)
};
this.logDialog = true;
},
submitLogReading() {
this.$emit('log-reading', {
apId: this.logTarget.id,
asset_pm_meter_id: this.logForm.asset_pm_meter_id,
reading: Number(this.logForm.reading),
reading_date: this.logForm.reading_date,
source: 'manual'
});
this.logDialog = false;
},
onCompletePhotos(event) {
for (const file of Array.from(event.target.files || [])) {
const reader = new FileReader();
@@ -975,7 +1064,10 @@ export default {
notes_list: this.completeNotesList.map((n) => String(n || '').trim()).filter(Boolean),
ratings: { ...this.completeRatings },
signature: this.completeSignature,
photos: this.completePhotos
photos: this.completePhotos,
meter_readings: this.completeMeters
.filter((m) => m.reading !== '' && m.reading != null)
.map((m) => ({ asset_pm_meter_id: m.asset_pm_meter_id, reading: Number(m.reading) }))
});
this.completeDialog = false;
}
+242
View File
@@ -0,0 +1,242 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Activity &amp; audit trail</h2>
<p class="quiet text-body-2">
Complete record of user activity — sign-ins, lockouts, password and permission changes, and every data edit.
</p>
</div>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-download" :loading="exporting" @click="exportCsv">Export CSV</v-btn>
</div>
<div class="filter-grid mb-3">
<v-select
v-model="filters.actor"
:items="actorItems"
item-title="title"
item-value="value"
label="User"
density="compact"
hide-details
clearable
/>
<v-select
v-model="filters.action"
:items="facets.actions"
label="Action"
density="compact"
hide-details
clearable
/>
<v-select
v-model="filters.entity_type"
:items="facets.entity_types"
label="Entity type"
density="compact"
hide-details
clearable
/>
<v-text-field v-model="filters.from" type="date" label="From" density="compact" hide-details clearable />
<v-text-field v-model="filters.to" type="date" label="To" density="compact" hide-details clearable />
<v-text-field
v-model="filters.q"
label="Search"
placeholder="entity, id, action…"
density="compact"
hide-details
clearable
prepend-inner-icon="mdi-magnify"
@keyup.enter="applyFilters"
/>
</div>
<div class="toolbar-row mb-3">
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-filter" @click="applyFilters">Apply filters</v-btn>
<v-btn size="small" variant="text" @click="resetFilters">Reset</v-btn>
<v-spacer />
<span class="quiet text-body-2">{{ total }} event{{ total === 1 ? '' : 's' }}</span>
</div>
<v-alert v-if="error" type="error" density="compact" class="mb-3">{{ error }}</v-alert>
<v-table density="compact" class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>ID</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td class="nowrap">{{ shortDate(row.created_at) }}</td>
<td>{{ row.actor_name || (row.actor_id ? `#${row.actor_id}` : 'System') }}</td>
<td><v-chip size="x-small" variant="tonal" :color="actionColor(row.action)">{{ row.action }}</v-chip></td>
<td>{{ row.entity_type }}</td>
<td class="nowrap">{{ row.entity_id || '—' }}</td>
<td>
<v-menu v-if="row.before_json || row.after_json" location="start">
<template #activator="{ props }">
<v-btn v-bind="props" size="x-small" variant="text" icon="mdi-information-outline" />
</template>
<v-card class="detail-card pa-3">
<div v-if="row.before_json" class="mb-2">
<div class="quiet text-caption mb-1">Before</div>
<pre class="detail-json">{{ pretty(row.before_json) }}</pre>
</div>
<div v-if="row.after_json">
<div class="quiet text-caption mb-1">After</div>
<pre class="detail-json">{{ pretty(row.after_json) }}</pre>
</div>
</v-card>
</v-menu>
<span v-else class="quiet">—</span>
</td>
</tr>
<tr v-if="!rows.length && !loading">
<td colspan="6" class="text-center quiet py-4">No activity matches these filters.</td>
</tr>
</tbody>
</v-table>
<div class="toolbar-row mt-3">
<v-spacer />
<v-pagination
v-if="pageCount > 1"
v-model="page"
:length="pageCount"
:total-visible="5"
density="compact"
@update:model-value="load"
/>
</div>
</v-card>
</template>
<script>
import { apiRequest, saveBlob } from '../services/api';
import { shortDate } from '../utils/format';
const BLANK_FILTERS = { actor: null, action: null, entity_type: null, from: null, to: null, q: '' };
export default {
props: {
token: { type: String, required: true }
},
data() {
return {
filters: { ...BLANK_FILTERS },
rows: [],
facets: { actions: [], entity_types: [], actors: [] },
total: 0,
page: 1,
pageSize: 25,
loading: false,
exporting: false,
error: ''
};
},
computed: {
pageCount() {
return Math.max(1, Math.ceil(this.total / this.pageSize));
},
actorItems() {
return this.facets.actors.map((a) => ({ title: a.name || `#${a.id}`, value: a.id }));
}
},
mounted() {
this.load();
},
methods: {
shortDate,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
queryString() {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(this.filters)) {
if (value !== null && value !== '' && value !== undefined) params.set(key, value);
}
return params;
},
async load() {
this.loading = true;
this.error = '';
try {
const params = this.queryString();
params.set('page', this.page);
params.set('pageSize', this.pageSize);
const data = await (await this.api(`/api/audit-logs?${params.toString()}`)).json();
this.rows = data.rows;
this.total = data.total;
this.facets = data.facets;
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
applyFilters() {
this.page = 1;
this.load();
},
resetFilters() {
this.filters = { ...BLANK_FILTERS };
this.page = 1;
this.load();
},
async exportCsv() {
this.exporting = true;
this.error = '';
try {
const response = await this.api(`/api/audit-logs/export?${this.queryString().toString()}`);
const blob = await response.blob();
saveBlob(blob, `audit-trail-${new Date().toISOString().slice(0, 10)}.csv`);
} catch (error) {
this.error = error.message;
} finally {
this.exporting = false;
}
},
pretty(jsonText) {
try {
return JSON.stringify(JSON.parse(jsonText), null, 2);
} catch {
return jsonText;
}
},
actionColor(action) {
if (/fail|locked|delete/.test(action)) return 'error';
if (/login|create/.test(action)) return 'success';
if (/update|change|export/.test(action)) return 'info';
return undefined;
}
}
};
</script>
<style scoped>
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
}
.audit-table .nowrap {
white-space: nowrap;
}
.detail-card {
max-width: 520px;
max-height: 60vh;
overflow: auto;
}
.detail-json {
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
</style>
+28 -11
View File
@@ -25,6 +25,11 @@
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
<template #cell-asset_class_number="{ row }">
<span v-if="row.asset_class_label" :title="row.asset_class_label">{{ row.asset_class_label }}</span>
<span v-else-if="row.asset_class_number">{{ row.asset_class_number }}</span>
<span v-else class="quiet">—</span>
</template>
<template #cell-id_template_name="{ row }">
<span v-if="row.id_template_name">{{ row.id_template_name }} <span class="quiet">({{ row.id_template_pattern }})</span></span>
<span v-else class="quiet">Default (MA-#####)</span>
@@ -59,6 +64,7 @@
label="Asset class number (optional)"
:hint="classHint || 'Shared by every asset in this category'"
persistent-hint
@update:model-value="onClassNumberInput"
/>
<v-select
v-model="form.id_template_id"
@@ -133,7 +139,7 @@ export default {
dialog: false,
deleteDialog: false,
deleteTarget: null,
form: { id: null, name: '', code: '', asset_class_number: '', id_template_id: null },
form: { id: null, name: '', code: '', asset_class_id: null, asset_class_number: '', id_template_id: null },
saving: false,
error: '',
dialogError: '',
@@ -155,14 +161,18 @@ export default {
},
assetClassItems() {
const tableLabels = { common: 'Common', industry: 'Manufacturing', business: 'Business', custom: 'Custom' };
return this.pickableClasses.map((c, index) => ({
// Value is the class's unique id — class numbers are not unique, so an index/number would
// collide (e.g. 00.12 is both Computers and Casinos).
return this.pickableClasses.map((c) => ({
title: `${c.class_number} — ${c.description} (GDS ${c.gds ?? '—'} / ADS ${c.ads ?? '—'}) · ${tableLabels[c.table] || 'Common'}`,
value: index
value: c.id
}));
},
classHint() {
const match = this.assetClasses.find((c) => c.class_number && c.class_number === String(this.form.asset_class_number || '').trim());
if (!match) return '';
const match = this.form.asset_class_id ? this.assetClasses.find((c) => c.id === this.form.asset_class_id) : null;
if (!match) {
return this.form.asset_class_number ? `Manual entry — not linked to a catalog class.` : '';
}
return `IRS ${match.class_number} · ${match.description} · GDS ${match.gds ?? '—'} / ADS ${match.ads ?? '—'} yrs`;
}
},
@@ -200,20 +210,27 @@ export default {
this.assetClasses = [];
}
},
applyAssetClass(index) {
const entry = index != null ? this.pickableClasses[index] : null;
if (entry) this.form.asset_class_number = entry.class_number;
applyAssetClass(id) {
const entry = id != null ? this.assetClasses.find((c) => c.id === id) : null;
if (entry) {
this.form.asset_class_id = entry.id;
this.form.asset_class_number = entry.class_number;
}
this.$nextTick(() => { this.classLookup = null; });
},
onClassNumberInput() {
// Typing a number by hand detaches it from a catalog class (manual override).
this.form.asset_class_id = null;
},
openNew() {
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
this.form = { id: null, name: '', code: '', asset_class_number: '', id_template_id: null };
this.form = { id: null, name: '', code: '', asset_class_id: null, asset_class_number: '', id_template_id: null };
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
this.form = { id: row.id, name: row.name, code: row.code || '', asset_class_number: row.asset_class_number || '', id_template_id: row.id_template_id || null };
this.form = { id: row.id, name: row.name, code: row.code || '', asset_class_id: row.asset_class_id || null, asset_class_number: row.asset_class_number || '', id_template_id: row.id_template_id || null };
this.dialogError = '';
this.dialog = true;
},
@@ -225,7 +242,7 @@ export default {
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/asset-categories/${this.form.id}` : '/api/asset-categories';
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null, asset_class_number: this.form.asset_class_number || null, id_template_id: this.form.id_template_id || null }) });
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null, asset_class_id: this.form.asset_class_id || null, asset_class_number: this.form.asset_class_number || null, id_template_id: this.form.id_template_id || null }) });
this.dialog = false;
await this.load();
this.$emit('updated');
+132
View File
@@ -0,0 +1,132 @@
<template>
<v-card class="panel-pad" :class="standalone ? 'change-password-standalone' : 'span-12'">
<h2 class="section-title mb-1">{{ forced ? 'Set a new password' : 'Change your password' }}</h2>
<p v-if="forced" class="quiet text-body-2 mb-3">
Your password must be changed before you can continue.
</p>
<v-alert v-if="rules.length" type="info" variant="tonal" density="compact" class="mb-3">
<div class="text-caption font-weight-medium mb-1">Password requirements</div>
<ul class="rule-list">
<li v-for="rule in rules" :key="rule">{{ rule }}</li>
</ul>
</v-alert>
<v-text-field
v-model="current"
type="password"
label="Current password"
autocomplete="current-password"
prepend-inner-icon="mdi-lock-outline"
density="compact"
/>
<v-text-field
v-model="next"
type="password"
label="New password"
autocomplete="new-password"
prepend-inner-icon="mdi-lock-plus-outline"
density="compact"
/>
<v-text-field
v-model="confirm"
type="password"
label="Confirm new password"
autocomplete="new-password"
prepend-inner-icon="mdi-lock-check-outline"
density="compact"
:error-messages="confirm && confirm !== next ? 'Passwords do not match' : ''"
@keyup.enter="submit"
/>
<v-alert v-if="error" type="error" density="compact" class="mb-3">{{ error }}</v-alert>
<v-alert v-if="success" type="success" density="compact" class="mb-3">Password updated.</v-alert>
<div class="toolbar-row">
<v-btn v-if="forced" variant="text" @click="$emit('cancel')">Sign out</v-btn>
<v-spacer />
<v-btn
color="primary"
prepend-icon="mdi-content-save"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
>Update password</v-btn>
</div>
</v-card>
</template>
<script>
import { apiRequest } from '../services/api';
export default {
props: {
token: { type: String, required: true },
forced: { type: Boolean, default: false },
standalone: { type: Boolean, default: false }
},
emits: ['changed', 'cancel'],
data() {
return {
current: '',
next: '',
confirm: '',
rules: [],
saving: false,
error: '',
success: false
};
},
computed: {
canSubmit() {
return this.current && this.next && this.next === this.confirm;
}
},
mounted() {
this.loadRules();
},
methods: {
async loadRules() {
try {
const data = await (await apiRequest('/api/profile', this.token)).json();
this.rules = data.passwordPolicy?.rules || [];
} catch {
this.rules = [];
}
},
async submit() {
if (!this.canSubmit) return;
this.saving = true;
this.error = '';
this.success = false;
try {
await apiRequest('/api/profile/password', this.token, {
method: 'PUT',
body: JSON.stringify({ current_password: this.current, new_password: this.next })
});
this.success = true;
this.current = '';
this.next = '';
this.confirm = '';
this.$emit('changed');
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
}
}
};
</script>
<style scoped>
.change-password-standalone {
max-width: 480px;
margin: 8vh auto;
}
.rule-list {
padding-left: 18px;
margin: 0;
line-height: 1.6;
}
</style>
+9 -2
View File
@@ -51,8 +51,12 @@
<v-text-field v-model="form.real_property_end" type="date" label="Real-property PIS end (optional)" />
<v-text-field v-model.number="form.max_recovery_years" type="number" label="Max recovery years (optional)" />
<v-text-field v-model.number="form.leasehold_life_years" type="number" label="Leasehold improvement life (yrs, optional)" />
<v-text-field v-model.number="form.section_179_increase" type="number" label="§179 limit increase ($)" prefix="$" hint="Enterprise/empowerment zones, e.g. 35000" persistent-hint />
<v-text-field v-model.number="form.section_179_increase" type="number" label="§179 limit increase ($)" prefix="$" hint="e.g. 35000 (Enterprise), 100000 (GO Zone)" persistent-hint />
<v-text-field v-model.number="form.section_179_cost_factor" type="number" step="0.01" label="§179 cost factor for phaseout" hint="Share of cost counted toward the threshold (e.g. 0.5)" persistent-hint />
<v-text-field v-model.number="form.section_179_threshold_increase" type="number" label="§179 threshold increase cap ($)" prefix="$" hint="Threshold rises by lesser of this and cost (GO Zone: 600000)" persistent-hint />
<div></div>
<v-text-field v-model="form.section_179_pis_start" type="date" label="§179 window start (optional)" hint="Defaults to the allowance window" persistent-hint />
<v-text-field v-model="form.section_179_pis_end" type="date" label="§179 window end (optional)" hint="GO Zone §179 ends earlier than the allowance" persistent-hint />
</div>
<v-textarea v-model="form.notes" class="mt-2" label="Notes" rows="3" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
@@ -92,7 +96,8 @@ function blankForm() {
return {
exists: false, code: '', name: '', allowance_percent: 0, enabled: true,
pis_start: '', pis_end: '', real_property_end: '', max_recovery_years: null, leasehold_life_years: null,
section_179_increase: 0, section_179_cost_factor: 1, notes: ''
section_179_increase: 0, section_179_cost_factor: 1, section_179_threshold_increase: 0,
section_179_pis_start: '', section_179_pis_end: '', notes: ''
};
}
@@ -149,6 +154,8 @@ export default {
max_recovery_years: row.max_recovery_years, leasehold_life_years: row.leasehold_life_years,
section_179_increase: row.section_179_increase || 0,
section_179_cost_factor: row.section_179_cost_factor === null || row.section_179_cost_factor === undefined ? 1 : row.section_179_cost_factor,
section_179_threshold_increase: row.section_179_threshold_increase || 0,
section_179_pis_start: row.section_179_pis_start || '', section_179_pis_end: row.section_179_pis_end || '',
notes: row.notes || ''
};
this.dialogError = '';
+2 -2
View File
@@ -8,13 +8,13 @@ import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
// Configure Monaco web workers once (Vite bundles each ?worker as its own chunk).
if (!window.__mixedassetsMonacoEnv) {
if (!window.__deprecoreMonacoEnv) {
self.MonacoEnvironment = {
getWorker(_workerId, label) {
return label === 'json' ? new jsonWorker() : new editorWorker();
}
};
window.__mixedassetsMonacoEnv = true;
window.__deprecoreMonacoEnv = true;
}
export default {
+178
View File
@@ -0,0 +1,178 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-1">
<div>
<h2 class="section-title">Password &amp; account security</h2>
<p class="quiet text-body-2">
Rules enforced for locally managed users — applied when passwords are set or changed, and on sign-in.
</p>
</div>
</div>
<v-alert v-if="error" type="error" density="compact" class="mb-3">{{ error }}</v-alert>
<div class="policy-grid">
<div>
<h3 class="section-title text-body-1 mb-2">Complexity</h3>
<div class="form-grid">
<v-text-field
v-model.number="policy.min_length"
type="number"
min="4"
label="Minimum length"
density="compact"
/>
</div>
<v-switch v-model="policy.require_upper" label="Require an uppercase letter" color="primary" density="compact" hide-details />
<v-switch v-model="policy.require_lower" label="Require a lowercase letter" color="primary" density="compact" hide-details />
<v-switch v-model="policy.require_number" label="Require a number" color="primary" density="compact" hide-details />
<v-switch v-model="policy.require_symbol" label="Require a symbol" color="primary" density="compact" hide-details />
<v-switch v-model="policy.disallow_identifiers" label="Disallow name or email in password" color="primary" density="compact" hide-details />
</div>
<div>
<h3 class="section-title text-body-1 mb-2">Rotation &amp; reuse</h3>
<div class="form-grid">
<v-text-field
v-model.number="policy.history_count"
type="number"
min="0"
label="Block reuse of last N passwords"
hint="0 disables password history"
persistent-hint
density="compact"
/>
<v-text-field
v-model.number="policy.expiry_days"
type="number"
min="0"
label="Expire passwords after (days)"
hint="0 = never expires"
persistent-hint
density="compact"
/>
<v-text-field
v-model.number="policy.min_age_hours"
type="number"
min="0"
label="Minimum password age (hours)"
hint="0 = can change anytime"
persistent-hint
density="compact"
/>
</div>
<h3 class="section-title text-body-1 mb-2 mt-4">Account lockout</h3>
<div class="form-grid">
<v-text-field
v-model.number="policy.lockout_threshold"
type="number"
min="0"
label="Lock after N failed sign-ins"
hint="0 disables lockout"
persistent-hint
density="compact"
/>
<v-text-field
v-model.number="policy.lockout_window_minutes"
type="number"
min="1"
label="Lock duration (minutes)"
density="compact"
/>
</div>
</div>
<div>
<h3 class="section-title text-body-1 mb-2">What users will see</h3>
<v-alert type="info" variant="tonal" density="comfortable">
<ul class="rule-list">
<li v-for="rule in rules" :key="rule">{{ rule }}</li>
</ul>
</v-alert>
</div>
</div>
<v-divider class="my-4" />
<div class="toolbar-row">
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save policy</v-btn>
</div>
</v-card>
</template>
<script>
import { apiRequest } from '../services/api';
export default {
props: {
token: { type: String, required: true }
},
data() {
return {
policy: {
min_length: 12,
require_upper: true,
require_lower: true,
require_number: true,
require_symbol: true,
disallow_identifiers: true,
history_count: 5,
expiry_days: 90,
min_age_hours: 0,
lockout_threshold: 5,
lockout_window_minutes: 15
},
rules: [],
saving: false,
error: ''
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/password-policy')).json();
this.policy = { ...this.policy, ...data.policy };
this.rules = data.rules;
} catch (error) {
this.error = error.message;
}
},
async save() {
this.saving = true;
this.error = '';
try {
const data = await (await this.api('/api/password-policy', {
method: 'PUT',
body: JSON.stringify({ policy: this.policy })
})).json();
this.policy = { ...this.policy, ...data.policy };
this.rules = data.rules;
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
}
}
};
</script>
<style scoped>
.policy-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
}
.rule-list {
padding-left: 18px;
line-height: 1.7;
margin: 0;
}
</style>
+117 -1
View File
@@ -7,6 +7,75 @@
<v-select v-model="form.pm_default_frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Default frequency unit" />
<v-text-field v-model.number="form.pm_lead_days" type="number" label="Alert lead days" />
</div>
<v-divider class="my-4" />
<h3 class="section-title text-body-1 mb-1">Dynamic scheduling</h3>
<p class="quiet text-body-2 mb-2">
Beyond static dates, PM due-dates can auto-adjust to real-world signals. With these on, a schedule becomes
due at the <strong>earliest</strong> of its calendar date, a projected usage threshold, or an age-compressed date.
</p>
<v-switch
v-model="form.pm_usage_tracking_enabled"
color="primary"
density="compact"
hide-details
label="Usage-based scheduling (meters: hours, kWh, gallons, cycles…)"
/>
<v-switch
v-model="form.pm_lifespan_curve_enabled"
color="primary"
density="compact"
hide-details
label="Lifespan wear curve (service more often as equipment ages)"
/>
<div v-if="form.pm_lifespan_curve_enabled" class="form-grid mt-2">
<v-text-field
v-model.number="form.pm_lifespan_min_factor"
type="number" step="0.05" min="0.1" max="1"
label="End-of-life interval factor"
hint="0.5 = half the interval at end of life"
persistent-hint
/>
<v-text-field
v-model.number="form.pm_lifespan_curve_exponent"
type="number" step="0.5" min="0.5" max="6"
label="Curve aggressiveness (exponent)"
hint="Higher holds the base cadence longer, then drops sharply"
persistent-hint
/>
</div>
<v-alert v-if="form.pm_lifespan_curve_enabled" type="info" variant="tonal" density="compact" class="mt-2">
{{ curvePreview }}
</v-alert>
<v-divider class="my-4" />
<h3 class="section-title text-body-1 mb-1">Nightly schedule recompute</h3>
<p class="quiet text-body-2 mb-2">
A background job re-derives projected due-dates so usage and age-driven schedules stay current even when an asset
isn’t serviced or read for a while. Runs once a day at the chosen hour (server time).
</p>
<v-switch
v-model="form.pm_recompute_enabled"
color="primary"
density="compact"
hide-details
label="Enable nightly recompute"
/>
<div v-if="form.pm_recompute_enabled" class="form-grid mt-2">
<v-select
v-model.number="form.pm_recompute_hour"
:items="hourItems"
item-title="title"
item-value="value"
label="Run at (hour, server time)"
/>
</div>
<div class="d-flex align-center mt-2" style="gap:12px">
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" :loading="recomputing" @click="runNow">Run now</v-btn>
<span class="quiet text-body-2">Last run: {{ form.pm_last_recompute_at ? shortDate(form.pm_last_recompute_at) : 'Never' }}</span>
</div>
<div class="toolbar-row mt-3">
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save PM defaults</v-btn>
@@ -18,6 +87,7 @@
<script>
import { apiRequest } from '../services/api';
import { pmFrequencyUnits } from '../constants';
import { shortDate } from '../utils/format';
export default {
props: {
@@ -26,16 +96,47 @@ export default {
data() {
return {
pmFrequencyUnits,
form: { pm_default_frequency_value: 3, pm_default_frequency_unit: 'months', pm_lead_days: 7 },
form: {
pm_default_frequency_value: 3,
pm_default_frequency_unit: 'months',
pm_lead_days: 7,
pm_usage_tracking_enabled: false,
pm_lifespan_curve_enabled: false,
pm_lifespan_min_factor: 0.5,
pm_lifespan_curve_exponent: 2,
pm_recompute_enabled: false,
pm_recompute_hour: 2,
pm_last_recompute_at: null
},
recomputing: false,
saving: false,
status: '',
statusType: 'success'
};
},
computed: {
hourItems() {
return Array.from({ length: 24 }, (_, h) => ({
title: `${String(h).padStart(2, '0')}:00`,
value: h
}));
},
// Plain-English example of the wear curve at the half-life point.
curvePreview() {
const min = Math.min(1, Math.max(0.1, Number(this.form.pm_lifespan_min_factor) || 0.5));
const exp = Math.min(6, Math.max(0.5, Number(this.form.pm_lifespan_curve_exponent) || 2));
const halfFactor = 1 - (1 - min) * Math.pow(0.5, exp);
const base = 6; // months, illustrative
const atHalf = Math.round(base * halfFactor * 10) / 10;
const atEnd = Math.round(base * min * 10) / 10;
return `Example: a ${base}-month interval becomes about ${atHalf} months at mid-life and ${atEnd} months at end of life.`;
}
},
mounted() {
this.load();
},
methods: {
shortDate,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
@@ -43,6 +144,21 @@ export default {
const data = await (await this.api('/api/pm-settings')).json();
this.form = { ...this.form, ...data.settings };
},
async runNow() {
this.recomputing = true;
this.status = '';
try {
const data = await (await this.api('/api/pm-recompute', { method: 'POST', body: '{}' })).json();
this.form = { ...this.form, ...data.settings };
this.statusType = 'success';
this.status = `Recompute complete — ${data.result.updated} of ${data.result.scanned} schedule(s) updated.`;
} catch (error) {
this.statusType = 'error';
this.status = error.message;
} finally {
this.recomputing = false;
}
},
async save() {
this.saving = true;
this.status = '';
+1 -1
View File
@@ -43,7 +43,7 @@
class="mt-2"
label="Field mapping (asset field → CMDB column, JSON)"
:rows="6"
hint="Maps MixedAssets asset fields to ServiceNow CI columns. Leave default to use the built-in hardware mapping."
hint="Maps DepreCore asset fields to ServiceNow CI columns. Leave default to use the built-in hardware mapping."
persistent-hint
:error-messages="mapError"
/>
+2 -1
View File
@@ -98,6 +98,7 @@
</v-card-text>
<v-card-actions>
<v-btn variant="text" prepend-icon="mdi-logout" @click="$emit('logout')">Sign out</v-btn>
<v-btn variant="text" prepend-icon="mdi-lock-reset" @click="profileMenu = false; $emit('change-password')">Change password</v-btn>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save</v-btn>
</v-card-actions>
@@ -118,7 +119,7 @@ export default {
title: { type: String, required: true },
user: { type: Object, required: true }
},
emits: ['logout', 'open-help', 'save-preferences', 'toggle-nav'],
emits: ['change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'],
data() {
return {
accentPresets,
+36 -7
View File
@@ -3,7 +3,7 @@
<v-card class="user-manual-print" height="90vh">
<v-toolbar color="primary" density="comfortable" class="px-2">
<v-icon icon="mdi-book-open-page-variant" class="ml-2 mr-2" />
<v-toolbar-title class="font-weight-bold">MixedAssets User Guide</v-toolbar-title>
<v-toolbar-title class="font-weight-bold">DepreCore User Guide</v-toolbar-title>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-printer" class="d-print-none" @click="print">Print / Save PDF</v-btn>
<v-btn icon="mdi-close" variant="text" class="d-print-none" @click="$emit('update:modelValue', false)" />
@@ -40,7 +40,7 @@
<!-- Content -->
<div ref="content" class="manual-content user-manual-content">
<div class="manual-doc-title d-none d-print-block">
<h1>MixedAssets User Guide</h1>
<h1>DepreCore User Guide</h1>
<p class="quiet">Fixed-asset management, depreciation, maintenance &amp; integrations</p>
</div>
@@ -48,7 +48,7 @@
<section v-show="isShown('overview')" class="manual-section">
<h2>Getting started</h2>
<p>
MixedAssets is an integrated system for tracking physical assets through their full lifecycle —
DepreCore is an integrated system for tracking physical assets through their full lifecycle —
acquisition, depreciation across multiple accounting books, preventative maintenance, disposal — alongside
a parts inventory, a contacts/CRM directory, an alerting engine, and connectors to email, webhooks, and ServiceNow.
</p>
@@ -102,7 +102,7 @@
</ul>
<h3>Accessibility</h3>
<p>MixedAssets is built to work with assistive technology:</p>
<p>DepreCore is built to work with assistive technology:</p>
<ul>
<li>Press <strong>Tab</strong> to reveal a “Skip to main content” link, and to move through controls with a clear keyboard focus outline.</li>
<li>Images and photos carry descriptive text for screen readers, and icon-only buttons have spoken labels.</li>
@@ -302,6 +302,16 @@
applicable year’s limit (less any dollar-for-dollar phaseout). If no limit is configured for an asset’s placed-in-service
year, the entered §179 amount is left uncapped.
</p>
<p>
A zone can combine both incentives. The seeded <strong>Qualified Gulf Opportunity (GO) Zone</strong> applies a
<strong>50% §168 allowance</strong> (placed in service 2005–2011) <em>and</em> a <strong>+$100,000 §179 increase</strong>
(which ends earlier, in 2008) with the phaseout threshold raised by the lesser of $600,000 or the property’s cost. To
support cases like this, each zone has an optional separate <em>§179 window</em> (so the §179 increase can expire before
the bonus allowance does) and a <em>§179 threshold-increase cap</em>. Assign the zone on the asset and the engine applies
each benefit only while the asset’s placed-in-service date is inside that benefit’s own window. Other seeded disaster/relief
zones follow the same pattern — e.g. the <strong>Qualified Recovery Assistance (Kansas Disaster Zone)</strong> (50% allowance
+ $100,000 §179 increase, placed in service 2007–2008).
</p>
<h3>The PM (maintenance) book</h3>
<p>
@@ -317,6 +327,25 @@
reports. You can save report configurations to rerun later and export results to PDF, Excel, or CSV. To print an individual
PM plan, run the PM plan report and export it.
</p>
<p>The catalog is grouped for audit-grade internal, board, and client reporting:</p>
<ul>
<li><strong>Journals &amp; registers</strong> — the <strong>Asset journal</strong> (a full per-asset dossier of financials,
maintenance history, notes, warranties, adjustments and disposals, with photo thumbnails embedded in the PDF/print
export), the <strong>Fixed asset journal</strong> (complete register), and GL <strong>journal entries</strong> (debit/credit)
for monthly depreciation, disposals, revaluations, and write-offs.</li>
<li><strong>Transactions</strong> — event listings for assets <strong>purchased</strong>, <strong>disposed</strong>,
<strong>revalued</strong>, and <strong>written off</strong>, each filterable by year and an optional month.</li>
<li><strong>Summaries</strong> — aggregated by category or method: asset, depreciation, YTD depreciation, purchase,
disposal, revaluation, and write-off summaries.</li>
<li><strong>Detail</strong> — <strong>Asset history</strong>, a chronological timeline of every event for one asset
(acquisition, edits, maintenance, notes, warranties, adjustments, and disposal).</li>
</ul>
<p class="quiet text-body-2">
Reporting conventions: a <strong>“…&nbsp;journal”</strong> is a double-entry GL posting (debits = credits); a
<strong>“…&nbsp;transaction”</strong> lists the underlying records. <strong>Revaluation</strong> reports cover
revaluation / write-up / write-down adjustments; <strong>write-off</strong> reports cover impairments plus abandonment
disposals. <em>Future depreciation calculation</em> is the multi-year projection.
</p>
</section>
<!-- TAX RULES -->
@@ -593,7 +622,7 @@
<section v-show="isShown('servicenow')" class="manual-section">
<h2>ServiceNow integration</h2>
<p>
MixedAssets connects to ServiceNow in two directions, both configured in <strong>Administration → ServiceNow integration</strong>:
DepreCore connects to ServiceNow in two directions, both configured in <strong>Administration → ServiceNow integration</strong>:
pushing <strong>incidents</strong> out, and pulling <strong>asset data</strong> in from the CMDB.
</p>
@@ -770,7 +799,7 @@
</v-alert>
</section>
<div class="manual-footer quiet">MixedAssets User Guide · select a topic on the left, or use Print / Save PDF for the full manual.</div>
<div class="manual-footer quiet">DepreCore User Guide · select a topic on the left, or use Print / Save PDF for the full manual.</div>
</div>
</div>
</v-card>
@@ -790,7 +819,7 @@ export default {
printing: false,
sampleRuleSet: [
'{',
' "schema": "mixedassets.taxRuleSet.v1",',
' "schema": "deprecore.taxRuleSet.v1",',
' "jurisdiction": "US-CA",',
' "name": "California depreciation",',
' "version": "2026.1",',
+2 -2
View File
@@ -8,12 +8,12 @@
<div class="form-grid">
<v-switch v-model="form.webhook_enabled" label="Enable webhook delivery" color="primary" density="compact" hide-details />
<div />
<v-text-field v-model="form.webhook_url" class="full" label="Webhook URL" placeholder="https://example.com/hooks/mixedassets" />
<v-text-field v-model="form.webhook_url" class="full" label="Webhook URL" placeholder="https://example.com/hooks/deprecore" />
<v-text-field
v-model="form.webhook_secret"
type="password"
:label="settings.webhook_secret_set ? 'Signing secret (unchanged)' : 'Signing secret (optional)'"
hint="If set, each request is signed with HMAC-SHA256 in the X-MixedAssets-Signature header"
hint="If set, each request is signed with HMAC-SHA256 in the X-DepreCore-Signature header"
persistent-hint
autocomplete="new-password"
/>
+9 -7
View File
@@ -99,15 +99,15 @@ export const customFieldTypes = [
];
export const themeItems = [
{ title: 'Dark', value: 'mixedAssetsDark', props: { subtitle: 'Default dark' } },
{ title: 'Light', value: 'mixedAssets', props: { subtitle: 'Default light' } },
{ title: 'Ocean', value: 'mixedAssetsOcean', props: { subtitle: 'Dark · teal' } },
{ title: 'Slate', value: 'mixedAssetsSlate', props: { subtitle: 'Dark · neutral' } },
{ title: 'High contrast', value: 'mixedAssetsContrast', props: { subtitle: 'Dark · accessible' } },
{ title: 'Sepia', value: 'mixedAssetsSepia', props: { subtitle: 'Light · warm' } }
{ title: 'Dark', value: 'depreCoreDark', props: { subtitle: 'Default dark' } },
{ title: 'Light', value: 'depreCore', props: { subtitle: 'Default light' } },
{ title: 'Ocean', value: 'depreCoreOcean', props: { subtitle: 'Dark · teal' } },
{ title: 'Slate', value: 'depreCoreSlate', props: { subtitle: 'Dark · neutral' } },
{ title: 'High contrast', value: 'depreCoreContrast', props: { subtitle: 'Dark · accessible' } },
{ title: 'Sepia', value: 'depreCoreSepia', props: { subtitle: 'Light · warm' } }
];
export const darkThemes = ['mixedAssetsDark', 'mixedAssetsOcean', 'mixedAssetsSlate', 'mixedAssetsContrast'];
export const darkThemes = ['depreCoreDark', 'depreCoreOcean', 'depreCoreSlate', 'depreCoreContrast'];
// Accessibility: user-adjustable base text size (scales the whole UI via the root font size).
export const fontSizeItems = [
@@ -170,6 +170,8 @@ export const navItems = [
group: true,
children: [
{ key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' },
{ key: 'admin-security', label: 'Password & Security', icon: 'mdi-shield-key-outline' },
{ key: 'admin-audit', label: 'Activity & Audit Trail', icon: 'mdi-history' },
{ key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' },
{ key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' }
]
+7 -7
View File
@@ -14,9 +14,9 @@ const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'mixedAssetsDark',
defaultTheme: 'depreCoreDark',
themes: {
mixedAssets: {
depreCore: {
dark: false,
colors: {
background: '#f7f8fb',
@@ -30,7 +30,7 @@ const vuetify = createVuetify({
error: '#b3261e'
}
},
mixedAssetsDark: {
depreCoreDark: {
dark: true,
colors: {
background: '#11161d',
@@ -44,7 +44,7 @@ const vuetify = createVuetify({
error: '#ffb4ab'
}
},
mixedAssetsOcean: {
depreCoreOcean: {
dark: true,
colors: {
background: '#0b1620',
@@ -58,7 +58,7 @@ const vuetify = createVuetify({
error: '#ff8f87'
}
},
mixedAssetsSlate: {
depreCoreSlate: {
dark: true,
colors: {
background: '#17191e',
@@ -72,7 +72,7 @@ const vuetify = createVuetify({
error: '#f2a59c'
}
},
mixedAssetsContrast: {
depreCoreContrast: {
dark: true,
colors: {
background: '#000000',
@@ -86,7 +86,7 @@ const vuetify = createVuetify({
error: '#ff5252'
}
},
mixedAssetsSepia: {
depreCoreSepia: {
dark: false,
colors: {
background: '#f3ead6',
+1 -1
View File
@@ -12,7 +12,7 @@ export async function apiRequest(path, token, options = {}) {
// An invalid/expired token (401) means the session is no longer valid — signal the
// app to drop back to the login screen. (403 is a role/permission denial, not a session issue.)
if (response.status === 401) {
window.dispatchEvent(new CustomEvent('mixedassets:unauthorized'));
window.dispatchEvent(new CustomEvent('deprecore:unauthorized'));
}
throw new Error(body.error || `Request failed: ${response.status}`);
}
+30 -6
View File
@@ -30,8 +30,11 @@
</template>
<template #cell-status="{ row }">
<span :class="['status-chip', row.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
<v-chip v-if="isLocked(row)" size="x-small" color="error" variant="tonal" class="ml-1" prepend-icon="mdi-lock">Locked</v-chip>
<v-chip v-if="row.must_change_password" size="x-small" color="warning" variant="tonal" class="ml-1" title="Must change password at next sign-in">Pwd reset</v-chip>
</template>
<template #actions="{ row }">
<v-btn v-if="isLocked(row)" icon="mdi-lock-open-variant" size="small" variant="text" color="warning" title="Unlock account" @click="unlockUser(row)" />
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(row)" />
</template>
</DataTable>
@@ -157,6 +160,16 @@
</v-dialog>
</template>
<!-- PASSWORD & SECURITY -->
<template v-if="adminSection === 'admin-security'">
<PasswordPolicySettings :token="token" />
</template>
<!-- ACTIVITY & AUDIT TRAIL -->
<template v-if="adminSection === 'admin-audit'">
<AuditTrailSettings :token="token" />
</template>
<!-- APPLICATION CONFIGURATION -->
<template v-if="adminSection === 'admin-config'">
<v-card class="span-6 panel-pad">
@@ -244,8 +257,9 @@
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
<v-select v-model="userForm.role" :items="roleSelectItems" item-title="title" item-value="value" label="Role" />
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" :hint="userForm.id ? 'Leave blank to keep the current password' : 'Leave blank to issue a temporary password'" persistent-hint />
</div>
<v-switch v-model="userForm.must_change_password" color="primary" density="compact" hide-details class="mt-2" label="Require password change at next sign-in" />
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
{{ roleSummary(userForm.role) }}
</v-alert>
@@ -278,7 +292,9 @@
<script>
import AssetClassSettings from '../components/AssetClassSettings.vue';
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
import AuditTrailSettings from '../components/AuditTrailSettings.vue';
import CategorySettings from '../components/CategorySettings.vue';
import PasswordPolicySettings from '../components/PasswordPolicySettings.vue';
import DataTable from '../components/DataTable.vue';
import DepartmentSettings from '../components/DepartmentSettings.vue';
import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue';
@@ -291,7 +307,7 @@ import ServiceNowSettings from '../components/ServiceNowSettings.vue';
import WebhookSettings from '../components/WebhookSettings.vue';
export default {
components: { AssetClassSettings, AssetIdTemplateSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, WebhookSettings },
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, WebhookSettings },
props: {
token: { type: String, default: '' },
section: { type: String, default: 'admin-users' },
@@ -309,7 +325,7 @@ export default {
workdaySaving: { type: Boolean, default: false },
workdaySyncing: { type: Boolean, default: false }
},
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
data() {
return {
localSettings: { ...this.settings },
@@ -358,7 +374,7 @@ export default {
return groups;
},
adminSection() {
return ['admin-users', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
return ['admin-users', 'admin-security', 'admin-audit', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
}
},
watch: {
@@ -384,7 +400,8 @@ export default {
team_id: null,
role: 'viewer',
status: 'active',
password: ''
password: '',
must_change_password: false
};
},
editUser(account) {
@@ -395,10 +412,17 @@ export default {
team_id: account.team_id,
role: account.role,
status: account.status,
password: ''
password: '',
must_change_password: Boolean(account.must_change_password)
};
this.userDialog = true;
},
isLocked(account) {
return Boolean(account.locked_until && new Date(account.locked_until).getTime() > Date.now());
},
unlockUser(account) {
this.$emit('unlock-user', account);
},
roleName(roleKey) {
return (this.roles.find((r) => r.key === roleKey) || {}).name || roleKey;
},
+1 -1
View File
@@ -330,7 +330,7 @@ export default {
method: 'POST',
body: JSON.stringify({ year: this.ledgerYear, format })
})).blob();
saveBlob(blob, `mixedassets-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
saveBlob(blob, `deprecore-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
}
}
};
+1 -1
View File
@@ -3,7 +3,7 @@
<v-card class="login-card" elevation="12">
<div class="brand-lockup">
<span class="brand-mark">MA</span>
<span class="brand-name">MixedAssets</span>
<span class="brand-name">DepreCore</span>
</div>
<v-divider />
<v-card-text class="pa-5">
+31 -1
View File
@@ -109,6 +109,31 @@
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.components.splice(index, 1)" />
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Usage meters</h3>
<v-spacer />
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addMeter">Add meter</v-btn>
</div>
<p class="quiet text-body-2">
Track usage (operating hours, kWh, gallons, cycles…) so service comes due on accumulated use, not just the calendar.
Requires usage-based scheduling in PM settings.
</p>
<div v-for="(meter, index) in form.meters" :key="`m${index}`" class="pm-component">
<v-text-field v-model="meter.label" density="compact" hide-details placeholder="Meter (e.g. Operating Hours)" style="flex:1 1 auto" />
<v-text-field v-model="meter.unit" density="compact" hide-details placeholder="Unit" style="max-width:100px" />
<v-text-field v-model.number="meter.usage_interval" type="number" density="compact" hide-details placeholder="Due every" style="max-width:130px" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.meters.splice(index, 1)" />
</div>
<v-switch
v-model="form.lifespan_adjust"
color="primary"
density="compact"
hide-details
class="mt-3"
label="Tighten the interval as the asset ages (lifespan wear curve)"
/>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Guidance photos</h3>
<v-spacer />
@@ -145,7 +170,7 @@ import { currency } from '../utils/format';
import { clonePlain } from '../utils/clone';
function blankPlan() {
return { id: null, name: '', description: '', category: '', frequency_value: 3, frequency_unit: 'months', estimated_minutes: null, instructions: '', steps: [], components: [], photos: [] };
return { id: null, name: '', description: '', category: '', frequency_value: 3, frequency_unit: 'months', estimated_minutes: null, instructions: '', steps: [], components: [], meters: [], lifespan_adjust: false, photos: [] };
}
export default {
@@ -248,6 +273,8 @@ export default {
...clonePlain(full),
steps: clonePlain(full.steps || []),
components: clonePlain(full.components || []),
meters: clonePlain(full.meters || []),
lifespan_adjust: Boolean(full.lifespan_adjust),
photos: clonePlain(full.photos || [])
};
this.dialog = true;
@@ -258,6 +285,9 @@ export default {
addComponent() {
this.form.components.push({ part_number: '', description: '', supplier: '', cost: null });
},
addMeter() {
this.form.meters.push({ label: '', unit: '', usage_interval: null });
},
onPhotoPick(event) {
for (const file of Array.from(event.target.files || [])) {
const reader = new FileReader();
+21 -1
View File
@@ -123,6 +123,26 @@
search-placeholder="Filter this report"
empty-text="No data for the selected options."
/>
<template v-if="report.meta && report.meta.sections">
<div v-for="section in report.meta.sections" :key="section.title" class="mt-5">
<h3 class="section-title mb-2">{{ section.title }}</h3>
<DataTable
:columns="section.columns"
:rows="section.rows"
:empty-text="`No ${section.title.toLowerCase()} recorded.`"
/>
</div>
</template>
<div v-if="report.meta && report.meta.photo_count" class="mt-5">
<h3 class="section-title mb-2">Photos ({{ report.meta.photo_count }})</h3>
<p class="quiet text-body-2">
<span v-if="report.meta.photos && report.meta.photos.length">{{ report.meta.photos.map((p) => p.caption).join(' · ') }}</span>
<span v-else>—</span>
</p>
<p class="quiet text-caption">Photo thumbnails are embedded in the PDF / print export.</p>
</div>
</div>
<div v-else class="quiet text-body-2">Select a report to begin.</div>
@@ -265,7 +285,7 @@ export default {
method: 'POST',
body: JSON.stringify({ type: this.selectedType, options: this.options, format })
})).blob();
saveBlob(blob, `mixedassets-${this.selectedType}.${format}`);
saveBlob(blob, `deprecore-${this.selectedType}.${format}`);
},
openSave() {
this.saveName = this.report?.title || 'Saved report';