394 lines
16 KiB
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>
|