Files
MixedAssets/src/views/PmView.vue

394 lines
16 KiB
Vue

<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Preventative maintenance plans</h2>
<div class="quiet text-body-2">Reusable maintenance procedures with a frequency and step checklist. Attach plans to assets from the asset's PM tab.</div>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New plan</v-btn>
</div>
<DataTable
:columns="columns"
:rows="plans"
row-key="id"
has-actions
searchable
search-placeholder="Filter plans"
empty-text="No PM plans yet. Create one to get started."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-frequency="{ row }">every {{ row.frequency_value }} {{ unitLabel(row.frequency_unit) }}</template>
<template #cell-steps="{ row }">{{ row.steps.length }}</template>
<template #cell-estimated_minutes="{ row }">{{ row.estimated_minutes ? row.estimated_minutes + ' min' : '' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-card class="span-12 panel-pad">
<h2 class="section-title mb-1">Completed PM forms</h2>
<p class="quiet text-body-2 mb-3">Signed completion records with condition ratings, notes, and photos.</p>
<DataTable
:columns="completionColumns"
:rows="completions"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter completions"
empty-text="No completed PM forms yet."
>
<template #cell-completed_at="{ row }">{{ (row.completed_at || '').slice(0, 10) }}</template>
<template #cell-rating_safety="{ row }">{{ row.rating_safety ? row.rating_safety + '' : '' }}</template>
<template #cell-photo_count="{ row }">{{ row.photo_count }}</template>
<template #actions="{ row }">
<v-btn size="small" variant="text" prepend-icon="mdi-file-eye-outline" @click="viewCompletion(row.id)">View</v-btn>
</template>
</DataTable>
</v-card>
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
<v-dialog v-model="dialog" max-width="720" scrollable>
<v-card>
<v-card-title>{{ form.id ? 'Edit PM plan' : 'New PM plan' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.name" class="full" label="Plan name" />
<v-combobox v-model="form.category" :items="categoryItems" label="Category" clearable />
<v-text-field :model-value="totalStepMinutes" type="number" label="Estimated minutes (from steps)" readonly hint="Auto-totaled from step times" persistent-hint />
<v-text-field v-model.number="form.frequency_value" type="number" label="Every" />
<v-select v-model="form.frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Frequency unit" />
<v-textarea v-model="form.description" class="full" label="Description" rows="2" />
<v-textarea v-model="form.instructions" class="full" label="Safety / general instructions" rows="2" />
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Steps</h3>
<v-spacer />
<span class="quiet text-caption">Total {{ totalStepMinutes }} min</span>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addStep">Add step</v-btn>
</div>
<p v-if="!form.steps.length" class="quiet text-body-2">No steps yet — add the procedure steps (e.g. "Tighten lug bolt A3").</p>
<div v-for="(step, index) in form.steps" :key="index" class="pm-step">
<div class="pm-step-num">{{ index + 1 }}</div>
<div class="pm-step-fields">
<div class="d-flex" style="gap:8px">
<v-text-field v-model="step.title" density="compact" hide-details placeholder="Step title" style="flex:1 1 auto" />
<v-text-field v-model.number="step.est_minutes" type="number" density="compact" hide-details placeholder="Min" style="max-width:88px" suffix="min" />
</div>
<v-text-field v-model="step.details" density="compact" hide-details placeholder="Details (optional)" class="mt-1" />
</div>
<div class="pm-step-actions">
<v-btn icon="mdi-arrow-up" size="x-small" variant="text" :disabled="index === 0" @click="moveStep(index, -1)" />
<v-btn icon="mdi-arrow-down" size="x-small" variant="text" :disabled="index === form.steps.length - 1" @click="moveStep(index, 1)" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.steps.splice(index, 1)" />
</div>
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Components / parts</h3>
<v-spacer />
<span class="quiet text-caption">Total {{ currency(totalComponentCost) }}</span>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addComponent">Add component</v-btn>
</div>
<p v-if="!form.components.length" class="quiet text-body-2">No components — add parts (part #, description, supplier, cost) to track PM cost.</p>
<div v-for="(component, index) in form.components" :key="`c${index}`" class="pm-component">
<v-text-field v-model="component.part_number" density="compact" hide-details placeholder="Part #" style="max-width:120px" />
<v-text-field v-model="component.description" density="compact" hide-details placeholder="Description" style="flex:1 1 auto" />
<v-text-field v-model="component.supplier" density="compact" hide-details placeholder="Supplier" style="max-width:140px" />
<v-text-field v-model.number="component.cost" type="number" density="compact" hide-details placeholder="Cost" prefix="$" style="max-width:110px" />
<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 />
<v-btn size="small" variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.planPhotoInput.click()">Add photos</v-btn>
<input ref="planPhotoInput" hidden type="file" accept="image/*" capture="environment" multiple @change="onPhotoPick" />
</div>
<p v-if="!form.photos.length" class="quiet text-body-2">Add reference photos (diagrams, part locations, before/after) shown to technicians during the service.</p>
<div v-else class="pm-plan-photo-grid">
<div v-for="(photo, index) in form.photos" :key="photo.id || `np${index}`" class="pm-plan-photo">
<div class="pm-plan-photo-frame">
<img :src="photo.data" :alt="photo.caption || 'guidance'" />
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="pm-plan-photo-remove" @click="form.photos.splice(index, 1)" />
</div>
<v-text-field v-model="photo.caption" density="compact" hide-details placeholder="Caption" class="mt-1" />
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!form.name" :loading="saving" @click="save">Save plan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import PmCompletionDialog from '../components/PmCompletionDialog.vue';
import { apiRequest } from '../services/api';
import { pmFrequencyUnits } from '../constants';
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: [], meters: [], lifespan_adjust: false, photos: [] };
}
export default {
components: { DataTable, PmCompletionDialog },
props: {
token: { type: String, required: true }
},
data() {
return {
pmFrequencyUnits,
plans: [],
pmCategories: [],
completions: [],
completionDialog: false,
completionId: null,
dialog: false,
form: blankPlan(),
saving: false,
error: '',
columns: [
{ key: 'name', label: 'Plan' },
{ key: 'category', label: 'Category' },
{ key: 'frequency', label: 'Frequency', sortable: false },
{ key: 'steps', label: 'Steps' },
{ key: 'estimated_minutes', label: 'Est. time' },
{ key: 'components_total', label: 'Parts cost', format: 'currency' }
],
completionColumns: [
{ key: 'asset_code', label: 'Asset' },
{ key: 'plan_name', label: 'Plan' },
{ key: 'completed_at', label: 'Completed', format: 'date' },
{ key: 'completed_by_name', label: 'By' },
{ key: 'rating_safety', label: 'Safety' },
{ key: 'photo_count', label: 'Photos', format: 'number' }
]
};
},
computed: {
categoryItems() {
// Admin-managed PM categories, plus any value already in use so existing plans stay selectable.
const inUse = this.plans.map((plan) => plan.category).filter(Boolean);
return [...new Set([...this.pmCategories, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
totalStepMinutes() {
return this.form.steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
},
totalComponentCost() {
return this.form.components.reduce((sum, component) => sum + (Number(component.cost) || 0), 0);
}
},
mounted() {
this.load();
this.loadCompletions();
this.loadCategories();
},
methods: {
currency,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/pm-plans')).json();
this.plans = data.plans;
},
async loadCompletions() {
const data = await (await this.api('/api/pm-completions')).json();
this.completions = data.completions;
},
async loadCategories() {
try {
const data = await (await this.api('/api/pm-categories')).json();
this.pmCategories = (data.categories || []).map((category) => category.name).filter(Boolean);
} catch {
this.pmCategories = [];
}
},
viewCompletion(id) {
this.completionId = id;
this.completionDialog = true;
},
unitLabel(unit) {
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
},
openNew() {
this.form = blankPlan();
this.error = '';
this.dialog = true;
},
async openEdit(plan) {
this.error = '';
// Fetch the full plan so guidance-photo image data (omitted from the list) is available.
let full = plan;
try {
const data = await (await this.api(`/api/pm-plans/${plan.id}`)).json();
full = data.plan;
} catch {
// fall back to the list row (photos will lack image data)
}
this.form = {
...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;
},
addStep() {
this.form.steps.push({ title: '', details: '', est_minutes: null });
},
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();
reader.onload = () => this.form.photos.push({ name: file.name, mime: file.type, data: String(reader.result), caption: '' });
reader.readAsDataURL(file);
}
event.target.value = '';
},
moveStep(index, dir) {
const target = index + dir;
const steps = this.form.steps;
[steps[index], steps[target]] = [steps[target], steps[index]];
},
async save() {
this.saving = true;
this.error = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/pm-plans/${this.form.id}` : '/api/pm-plans';
// Existing photos carry an id; resend only {id, caption} so the stored blob isn't re-uploaded.
const photos = (this.form.photos || []).map((p) => (p.id
? { id: p.id, caption: p.caption }
: { data: p.data, name: p.name, mime: p.mime, caption: p.caption }));
await this.api(url, { method, body: JSON.stringify({ ...this.form, photos }) });
this.dialog = false;
await this.load();
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
async remove(plan) {
this.error = '';
try {
await this.api(`/api/pm-plans/${plan.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.pm-step {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.pm-step-num {
flex: 0 0 26px;
height: 26px;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.15);
color: rgb(var(--v-theme-primary));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
margin-top: 4px;
}
.pm-step-fields {
flex: 1 1 auto;
}
.pm-step-actions {
display: flex;
align-items: center;
}
.pm-component {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.pm-plan-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.pm-plan-photo-frame {
position: relative;
aspect-ratio: 4 / 3;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
}
.pm-plan-photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pm-plan-photo-remove {
position: absolute;
top: 6px;
right: 6px;
}
</style>