Password Policy/App rename/PM Scheduling Improvements

This commit is contained in:
2026-06-06 02:32:47 -05:00
parent dfd999b6a9
commit f024286b5e
59 changed files with 3814 additions and 299 deletions

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;
}

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;
}

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>

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');

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>

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 = '';

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 {

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>

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
isnt 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 = '';

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"
/>

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,

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 years limit (less any dollar-for-dollar phaseout). If no limit is configured for an assets 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 20052011) <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 propertys 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 assets placed-in-service date is inside that benefits 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 20072008).
</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",',

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"
/>

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' }
]

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',

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}`);
}

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;
},

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}`);
}
}
};

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">

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();

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';