Password Policy/App rename/PM Scheduling Improvements
This commit is contained in:
132
src/App.vue
132
src/App.vue
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
src/components/AuditTrailSettings.vue
Normal file
242
src/components/AuditTrailSettings.vue
Normal 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 & 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>
|
||||
@@ -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
src/components/ChangePasswordCard.vue
Normal file
132
src/components/ChangePasswordCard.vue
Normal 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>
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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
src/components/PasswordPolicySettings.vue
Normal file
178
src/components/PasswordPolicySettings.vue
Normal 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 & 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 & 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>
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & 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 & 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>“… journal”</strong> is a double-entry GL posting (debits = credits); a
|
||||
<strong>“… 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",',
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
14
src/main.js
14
src/main.js
@@ -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',
|
||||
|
||||
@@ -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,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;
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user