Add the measuringtools plugin (ADR-005) and the plugin-system tutorial
Gage-lab instruments as Asset extensions: measuringtooltypes (color-coded lookup) + measuringtools (calibration interval/dates, provider, notes) with calibration status derived at read time (overdue / due soon / current / unknown), never stored. Full CRUD API with permission-gated writes, types management with in-use guard, calibration report, nav/reports/config-schema hooks, and a complete frontend (list/detail/form, types settings page, calibration report page, gated routes per ADR-009). First plugin whose migration chain really creates tables post-cutover (ADR-008), and the working example for docs/PLUGIN-GUIDE.md - a 12-section walkthrough of building a plugin on this framework, linked from PLUGIN-QUICKSTART and PLUGINS. Verified: full suite 323 passing, live E2E on all four pages, fresh scratch-MySQL migration dry-run green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
191
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal file
191
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tool Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="tool">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ tool.assetnumber }}</h1>
|
||||
<span v-if="tool.name" class="hero-alias">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-primary">
|
||||
{{ tool.assettypename || 'Measuring Tool' }}
|
||||
</span>
|
||||
<span class="badge badge-lg" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="tool.measuringtool?.measuringtooltypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ tool.measuringtool.measuringtooltypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="tool.measuringtool?.nextcalibrationdate">
|
||||
<span class="hero-detail-label">Next Calibration</span>
|
||||
<span class="hero-detail-value">{{ formatDate(tool.measuringtool.nextcalibrationdate) }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="tool.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ tool.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number (Gage Tag)</span>
|
||||
<span class="info-value">{{ tool.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ tool.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ tool.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.measuringtooltypename || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Calibration</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Status</span>
|
||||
<span class="info-value">
|
||||
<span class="badge" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Calibration</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.lastcalibrationdate ? formatDate(tool.measuringtool.lastcalibrationdate) : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Next Calibration</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.nextcalibrationdate ? formatDate(tool.measuringtool.nextcalibrationdate) : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.measuringtool?.calibrationintervaldays">
|
||||
<span class="info-label">Interval</span>
|
||||
<span class="info-value">{{ tool.measuringtool.calibrationintervaldays }} days</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.measuringtool?.calibrationprovider">
|
||||
<span class="info-label">Provider</span>
|
||||
<span class="info-value">{{ tool.measuringtool.calibrationprovider }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">{{ tool.locationname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ tool.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="tool.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="tool.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ tool.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDateTime(tool.createddate) }}<template v-if="tool.createdby"> by {{ tool.createdby }}</template></span>
|
||||
<span>Modified {{ formatDateTime(tool.modifieddate) }}<template v-if="tool.modifiedby"> by {{ tool.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Measuring tool not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const tool = ref(null)
|
||||
const { warranties } = useWarrantyBadge(() => tool.value?.assetid)
|
||||
|
||||
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
|
||||
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
|
||||
function formatDate(d) { if (!d) return '-'; return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function formatDateTime(d) { if (!d) return '-'; return new Date(d).toLocaleString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
tool.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
281
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal file
281
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Measuring Tool' : 'New Measuring Tool' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveTool">
|
||||
<!-- Identity Section -->
|
||||
<h3 class="form-section-title">Identity</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number (Gage Tag) *</label>
|
||||
<input id="assetnumber" v-model="form.assetnumber" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name / Alias</label>
|
||||
<input id="name" v-model="form.name" type="text" class="form-control" />
|
||||
<small class="form-help">Layperson-friendly label</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number (vendor)</label>
|
||||
<input id="serialnumber" v-model="form.serialnumber" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input id="gaugelabreference" v-model="form.gaugelabreference" type="text" class="form-control" />
|
||||
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="measuringtooltypeid">Type</label>
|
||||
<select id="measuringtooltypeid" v-model="form.measuringtooltypeid" class="form-control">
|
||||
<option value="">Select type...</option>
|
||||
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select id="statusid" v-model="form.statusid" class="form-control">
|
||||
<option value="">Select status...</option>
|
||||
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">{{ s.status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Section -->
|
||||
<h3 class="form-section-title">Calibration</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="lastcalibrationdate">Last Calibration</label>
|
||||
<input id="lastcalibrationdate" v-model="form.lastcalibrationdate" type="date" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="nextcalibrationdate">Next Calibration</label>
|
||||
<input id="nextcalibrationdate" v-model="form.nextcalibrationdate" type="date" class="form-control" />
|
||||
<small class="form-help">Drives the derived calibration status (overdue / due soon / current)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calibrationintervaldays">Interval (days)</label>
|
||||
<input id="calibrationintervaldays" v-model.number="form.calibrationintervaldays" type="number" min="0" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calibrationprovider">Calibration Provider</label>
|
||||
<input id="calibrationprovider" v-model="form.calibrationprovider" type="text" class="form-control" />
|
||||
<small class="form-help">Lab or in-house team that performs the calibration</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Section -->
|
||||
<h3 class="form-section-title">Location & Organization</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select id="locationid" v-model="form.locationid" class="form-control">
|
||||
<option value="">Select location...</option>
|
||||
<option v-for="l in locations" :key="l.locationid" :value="l.locationid">{{ l.locationname }}</option>
|
||||
</select>
|
||||
<small class="form-help">Operation locations (e.g. "0615 Blisk Inspection") hold measuring tools</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select id="businessunitid" v-model="form.businessunitid" class="form-control">
|
||||
<option value="">Select business unit...</option>
|
||||
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">{{ bu.businessunit }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<h3 class="form-section-title">Notes</h3>
|
||||
<div class="form-group">
|
||||
<textarea id="notes" v-model="form.notes" class="form-control" rows="3" placeholder="Additional notes..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Site-defined custom fields for measuring tools -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="assettypeid" :assetid="currentAssetId" />
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Measuring Tool' }}
|
||||
</button>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { measuringtoolsApi, assetsApi, locationsApi, businessunitsApi } from '../../api'
|
||||
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Resolved dynamically from /api/assets/types (no hardcoded id) so custom
|
||||
// fields load for the measuring_tool asset type.
|
||||
const assettypeid = ref(null)
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
gaugelabreference: '',
|
||||
serialnumber: '',
|
||||
statusid: '',
|
||||
measuringtooltypeid: '',
|
||||
lastcalibrationdate: '',
|
||||
nextcalibrationdate: '',
|
||||
calibrationintervaldays: null,
|
||||
calibrationprovider: '',
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const types = ref([])
|
||||
const statuses = ref([])
|
||||
const locations = ref([])
|
||||
const businessunits = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
||||
assetsApi.statuses.list(),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
assetsApi.types.list(),
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
statuses.value = statusResponse.data.data || []
|
||||
locations.value = locResponse.data.data || []
|
||||
businessunits.value = buResponse.data.data || []
|
||||
|
||||
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
||||
assettypeid.value = assetType ? assetType.assettypeid : null
|
||||
|
||||
if (isEdit.value) {
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
const data = response.data.data
|
||||
currentAssetId.value = data.assetid || null
|
||||
const ext = data.measuringtool || {}
|
||||
form.value = {
|
||||
assetnumber: data.assetnumber || '',
|
||||
name: data.name || '',
|
||||
gaugelabreference: data.gaugelabreference || '',
|
||||
serialnumber: data.serialnumber || '',
|
||||
statusid: data.statusid || '',
|
||||
measuringtooltypeid: ext.measuringtooltypeid || '',
|
||||
lastcalibrationdate: ext.lastcalibrationdate || '',
|
||||
nextcalibrationdate: ext.nextcalibrationdate || '',
|
||||
calibrationintervaldays: ext.calibrationintervaldays ?? null,
|
||||
calibrationprovider: ext.calibrationprovider || '',
|
||||
locationid: data.locationid || '',
|
||||
businessunitid: data.businessunitid || '',
|
||||
notes: data.notes || '',
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveTool() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const data = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
statusid: form.value.statusid || null,
|
||||
measuringtooltypeid: form.value.measuringtooltypeid || null,
|
||||
lastcalibrationdate: form.value.lastcalibrationdate || null,
|
||||
nextcalibrationdate: form.value.nextcalibrationdate || null,
|
||||
calibrationintervaldays: form.value.calibrationintervaldays ?? null,
|
||||
calibrationprovider: form.value.calibrationprovider || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
notes: form.value.notes || null,
|
||||
}
|
||||
|
||||
let saved
|
||||
if (isEdit.value) {
|
||||
const response = await measuringtoolsApi.update(route.params.id, data)
|
||||
saved = response.data.data
|
||||
} else {
|
||||
const response = await measuringtoolsApi.create(data)
|
||||
saved = response.data.data
|
||||
}
|
||||
|
||||
const assetId = saved.assetid
|
||||
if (assetId && customFieldsRef.value) {
|
||||
try {
|
||||
await customFieldsRef.value.save(assetId)
|
||||
} catch (customFieldsErr) {
|
||||
console.error('Error saving custom fields:', customFieldsErr)
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/measuringtools/${saved.measuringtool?.measuringtoolid || route.params.id}`)
|
||||
} catch (err) {
|
||||
console.error('Error saving measuring tool:', err)
|
||||
error.value = apiError(err, 'Failed to save measuring tool')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section-title:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
169
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal file
169
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tools</h2>
|
||||
<router-link to="/measuringtools/new" class="btn btn-primary">Add Measuring Tool</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search measuring tools..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="typeid" class="form-control" @change="reload">
|
||||
<option value="">All types</option>
|
||||
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="calibrationstatus" class="form-control" @change="reload">
|
||||
<option value="">All calibration statuses</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="duesoon">Due soon</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Name</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Next Calibration</th>
|
||||
<th>Calibration</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in tools" :key="item.assetid">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.measuringtool?.measuringtooltypename || '-' }}</td>
|
||||
<td>{{ item.measuringtool?.nextcalibrationdate ? formatDate(item.measuringtool.nextcalibrationdate) : '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(item.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tools.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No measuring tools found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
const tools = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const typeid = ref('')
|
||||
const calibrationstatus = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
|
||||
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
|
||||
types.value = typesResponse.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading types:', err)
|
||||
}
|
||||
loadTools()
|
||||
})
|
||||
|
||||
async function loadTools() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (typeid.value) params.typeid = typeid.value
|
||||
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
|
||||
|
||||
const response = await measuringtoolsApi.list(params)
|
||||
tools.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tools:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
page.value = 1
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(reload, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadTools()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/views/reports/CalibrationReport.vue
Normal file
101
frontend/src/views/reports/CalibrationReport.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Calibration Due</h1>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="summary-row">
|
||||
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
|
||||
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
|
||||
<span class="summary-label">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="b in bucketOrder" :key="b.key">
|
||||
<div class="bucket card" v-if="(buckets[b.key] || []).length">
|
||||
<h3 class="bucket-title">
|
||||
<span class="dot" :style="{ background: b.color }"></span>
|
||||
{{ b.label }} ({{ (buckets[b.key] || []).length }})
|
||||
</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Next Calibration</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in buckets[b.key]" :key="row.assetid">
|
||||
<td>
|
||||
<router-link :to="`/measuringtools/${row.measuringtool.measuringtoolid}`" class="asset-chip">
|
||||
{{ row.assetnumber }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ row.name || '-' }}</td>
|
||||
<td>{{ row.measuringtool.measuringtooltypename || '-' }}</td>
|
||||
<td>{{ row.measuringtool.nextcalibrationdate ? formatDate(row.measuringtool.nextcalibrationdate) : '-' }}</td>
|
||||
<td>{{ row.measuringtool.calibrationprovider || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
|
||||
const loading = ref(true)
|
||||
const counts = ref({})
|
||||
const buckets = ref({})
|
||||
|
||||
const bucketOrder = [
|
||||
{ key: 'overdue', label: 'Overdue', color: '#F44336' },
|
||||
{ key: 'duesoon', label: 'Due Soon', color: '#FF9800' },
|
||||
{ key: 'current', label: 'Current', color: '#4CAF50' },
|
||||
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
|
||||
]
|
||||
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await measuringtoolsApi.calibrationReport()
|
||||
counts.value = response.data.data.counts || {}
|
||||
buckets.value = response.data.data.buckets || {}
|
||||
} catch (err) {
|
||||
console.error('Error loading calibration report:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
.summary-card {
|
||||
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
|
||||
.summary-label { font-size: 0.85rem; color: var(--text-light); }
|
||||
.bucket { margin-bottom: 1.25rem; }
|
||||
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.asset-chip {
|
||||
display: inline-block; padding: 0.15rem 0.55rem;
|
||||
background: var(--bg); border-radius: 12px; font-size: 0.85rem; text-decoration: none; color: var(--text);
|
||||
}
|
||||
</style>
|
||||
157
frontend/src/views/settings/MeasuringToolTypesList.vue
Normal file
157
frontend/src/views/settings/MeasuringToolTypesList.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tool Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="mt in visibleTypes" :key="mt.measuringtooltypeid">
|
||||
<td>{{ mt.name }}</td>
|
||||
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(mt.color)">{{ mt.color || 'auto' }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span v-if="mt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(mt)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(mt)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleTypes.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No measuring tool types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Type' : 'Add Type' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="name">Type *</label>
|
||||
<input id="name" v-model="form.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const types = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleTypes = computed(() => showInactive.value ? types.value : types.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({ name: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await measuringtoolsApi.types.list({ perpage: 200, active: false })
|
||||
types.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { name: item.name || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { name: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function deleteType(mt) {
|
||||
if (!confirm(`Delete measuring tool type "${mt.name}"?`)) return
|
||||
try {
|
||||
await measuringtoolsApi.types.remove(mt.measuringtooltypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await measuringtoolsApi.types.update(editing.value.measuringtooltypeid, form.value)
|
||||
} else {
|
||||
await measuringtoolsApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user