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

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