Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two different machines were indistinguishable, a bookmark said nothing about what it pointed at, and a link shared into Teams previewed as the section rather than the thing. The router cannot fix this alone: afterEach runs before the view has fetched anything, so it only ever knows the KIND of page. So a detail view names its record once the record arrives, and both writers go through composables/pageTitle.js. THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that fetched quickly and would overwrite the good title with the generic one. The route's fullPath is claimed before that await, and the generic write stands down when a record title already belongs to the route being landed on. The reverse case is handled too: a view that names its record BEFORE the facility name arrives composed against the placeholder prefix, so the late resolve rewrites it rather than leaving "ShopDB - CSF16" on a site called West Jefferson. Both are pinned by tests, because both fail only under timing. Covers machines, PCs, printers, network devices, subnets, measuring tools, USB devices, printed parts, applications, employees, knowledge base articles and guides. Printers reuse the page's own displayTitle, which already discards the literal name "NONE" a discovered queue can carry, so the tab and the heading agree. A record with no usable name, or one that failed to load, keeps the section name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs. Long names are cut at a word boundary - knowledge base articles are named with a whole sentence of description, which a browser otherwise truncates mid-word. Reports gained meta.title, which the router already supported and nothing used. That also fixes "Pc Relationships", which the name-derived spelling produced.
380 lines
13 KiB
Vue
380 lines
13 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="item">
|
|
<div class="hero-card">
|
|
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
|
|
:alt="item.itemname" class="hero-image" />
|
|
<div class="hero-content">
|
|
<h2 class="hero-title">{{ item.itemname }}</h2>
|
|
<div class="hero-meta">
|
|
<span class="badge badge-secondary">{{ item.itemcode }}</span>
|
|
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
|
{{ item.quantityonhand }} on hand
|
|
</span>
|
|
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
|
|
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
|
</div>
|
|
<div class="hero-details">
|
|
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
|
</div>
|
|
<div class="hero-actions">
|
|
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
|
|
Restock
|
|
</button>
|
|
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
|
|
Adjust
|
|
</button>
|
|
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
|
class="btn btn-secondary btn-sm">Edit</router-link>
|
|
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
|
|
class="btn btn-secondary btn-sm">Part Label</router-link>
|
|
<button v-if="item.isactive" class="btn btn-danger btn-sm"
|
|
@click="retireItem">Retire</button>
|
|
<button v-else class="btn btn-primary btn-sm"
|
|
@click="restoreItem">Restore</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-grid">
|
|
<div class="content-column">
|
|
<div class="section-card">
|
|
<h3 class="section-title">Details</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Item code</span>
|
|
<span class="info-value">{{ item.itemcode }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="item.gagelabtag">
|
|
<span class="info-label">Gage lab tag</span>
|
|
<span class="info-value">{{ item.gagelabtag }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Bin location</span>
|
|
<span class="info-value">{{ item.binlocation || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Quantity on hand</span>
|
|
<span class="info-value">{{ item.quantityonhand }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Low-stock threshold</span>
|
|
<span class="info-value">{{ item.lowstockthreshold }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="item.printnotes">
|
|
<span class="info-label">Print notes</span>
|
|
<span class="info-value">{{ item.printnotes }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-column">
|
|
<div class="section-card">
|
|
<h3 class="section-title">Print files</h3>
|
|
<div class="file-upload-row">
|
|
<input ref="fileInput" type="file"
|
|
accept=".stl,.3mf,.gcode,.gco,.bgcode,.step,.stp,.obj,.amf" />
|
|
<input v-model="fileNote" type="text" class="form-control"
|
|
placeholder="What changed? (optional)" />
|
|
<button class="btn btn-primary btn-sm" :disabled="fileUploading"
|
|
@click="uploadRevision">
|
|
{{ fileUploading ? 'Uploading...' : 'Upload revision' }}
|
|
</button>
|
|
</div>
|
|
<div v-if="fileError" class="error-message">{{ fileError }}</div>
|
|
<ul class="file-revision-list">
|
|
<li v-for="revision in files" :key="revision.fileid"
|
|
:class="{ 'current-revision': revision === files[0] }">
|
|
<div class="file-main">
|
|
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)"
|
|
class="file-name">
|
|
{{ revision.filename }}
|
|
</a>
|
|
<span class="badge badge-secondary">rev {{ revision.revision }}</span>
|
|
<span v-if="revision === files[0]" class="badge badge-success">current</span>
|
|
</div>
|
|
<div class="file-meta">
|
|
{{ formatSize(revision.filesize) }} -
|
|
{{ revision.uploadedby }} -
|
|
{{ formatDate(revision.uploadeddate) }}
|
|
<span v-if="revision.uploadnote"> - {{ revision.uploadnote }}</span>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm file-delete"
|
|
@click="removeRevision(revision)">Delete</button>
|
|
</li>
|
|
<li v-if="files.length === 0" class="empty-state">
|
|
No print file uploaded yet
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="section-card">
|
|
<h3 class="section-title">Recent transactions</h3>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>When</th>
|
|
<th>Type</th>
|
|
<th>Qty</th>
|
|
<th>Rev</th>
|
|
<th>Who</th>
|
|
<th>Reason</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="transaction in item.recenttransactions"
|
|
:key="transaction.transactionid">
|
|
<td>{{ formatDate(transaction.transactiondate) }}</td>
|
|
<td>{{ transaction.transactiontype }}</td>
|
|
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
|
|
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
|
|
</td>
|
|
<td>{{ transaction.revision != null ? transaction.revision : '-' }}</td>
|
|
<td>{{ transaction.employeename || transaction.employeesso }}</td>
|
|
<td>{{ transaction.reason || '-' }}</td>
|
|
</tr>
|
|
<tr v-if="!item.recenttransactions?.length">
|
|
<td colspan="6" class="empty-state">No transactions yet</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="audit-footer">
|
|
Created {{ formatDate(item.createddate) }} -
|
|
Modified {{ formatDate(item.modifieddate) }}
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">Item not found</div>
|
|
|
|
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
|
|
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
|
|
<div class="form-group">
|
|
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
|
|
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
|
|
</div>
|
|
<div v-if="ledgerMode === 'adjust'" class="form-group">
|
|
<label>Reason *</label>
|
|
<input v-model="ledgerReason" type="text" class="form-control"
|
|
placeholder="e.g., damaged parts scrapped, recount" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Your badge / SSO *</label>
|
|
<input v-model="ledgerBadge" type="text" class="form-control"
|
|
placeholder="Scan badge or type SSO" />
|
|
</div>
|
|
<template #footer>
|
|
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
|
|
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
|
|
</button>
|
|
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
|
|
</template>
|
|
</Modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { printedpartsApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import Modal from '@/components/Modal.vue'
|
|
import { setRecordTitle } from '@/composables/pageTitle'
|
|
|
|
const route = useRoute()
|
|
const item = ref(null)
|
|
const loading = ref(true)
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await printedpartsApi.get(route.params.id)
|
|
item.value = response.data.data
|
|
setRecordTitle(item.value?.itemname || item.value?.itemcode)
|
|
await loadFiles()
|
|
} catch (loadError) {
|
|
console.error('Error loading printed item:', loadError)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
const files = ref([])
|
|
const fileInput = ref(null)
|
|
const fileNote = ref('')
|
|
const fileUploading = ref(false)
|
|
const fileError = ref('')
|
|
|
|
async function loadFiles() {
|
|
try {
|
|
const response = await printedpartsApi.listFiles(route.params.id)
|
|
files.value = response.data.data || []
|
|
} catch (filesError) {
|
|
console.error('Error loading files:', filesError)
|
|
}
|
|
}
|
|
|
|
async function uploadRevision() {
|
|
const file = fileInput.value?.files?.[0]
|
|
if (!file) { fileError.value = 'Choose a file first'; return }
|
|
fileUploading.value = true
|
|
fileError.value = ''
|
|
try {
|
|
await printedpartsApi.uploadFile(route.params.id, file, fileNote.value)
|
|
fileNote.value = ''
|
|
fileInput.value.value = ''
|
|
await loadFiles()
|
|
} catch (uploadError) {
|
|
fileError.value =
|
|
uploadError.response?.data?.data?.error?.message || 'Upload failed'
|
|
} finally {
|
|
fileUploading.value = false
|
|
}
|
|
}
|
|
|
|
async function removeRevision(revision) {
|
|
if (!window.confirm(
|
|
`Delete revision ${revision.revision} (${revision.filename})?`)) return
|
|
try {
|
|
await printedpartsApi.removeFile(revision.fileid)
|
|
await loadFiles()
|
|
} catch (removeError) {
|
|
fileError.value = 'Delete failed'
|
|
console.error(removeError)
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (!bytes && bytes !== 0) return '-'
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / 1048576).toFixed(1)} MB`
|
|
}
|
|
|
|
const ledgerOpen = ref(false)
|
|
const ledgerMode = ref('restock')
|
|
const ledgerQuantity = ref(null)
|
|
const ledgerReason = ref('')
|
|
const ledgerBadge = ref('')
|
|
const ledgerSaving = ref(false)
|
|
const ledgerError = ref('')
|
|
|
|
function openLedger(mode) {
|
|
ledgerMode.value = mode
|
|
ledgerQuantity.value = null
|
|
ledgerReason.value = ''
|
|
ledgerBadge.value = ''
|
|
ledgerError.value = ''
|
|
ledgerOpen.value = true
|
|
}
|
|
|
|
async function submitLedger() {
|
|
ledgerSaving.value = true
|
|
ledgerError.value = ''
|
|
try {
|
|
if (ledgerMode.value === 'restock') {
|
|
await printedpartsApi.restock(item.value.printeditemid, {
|
|
quantity: ledgerQuantity.value, badge: ledgerBadge.value
|
|
})
|
|
} else {
|
|
await printedpartsApi.adjust(item.value.printeditemid, {
|
|
quantitychange: ledgerQuantity.value,
|
|
reason: ledgerReason.value,
|
|
badge: ledgerBadge.value
|
|
})
|
|
}
|
|
ledgerOpen.value = false
|
|
const response = await printedpartsApi.get(item.value.printeditemid)
|
|
item.value = response.data.data
|
|
} catch (submitError) {
|
|
ledgerError.value =
|
|
submitError.response?.data?.data?.error?.message ||
|
|
submitError.response?.data?.error?.message || 'Submit failed'
|
|
} finally {
|
|
ledgerSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function retireItem() {
|
|
if (!window.confirm(
|
|
`Retire ${item.value.itemname}? It leaves the storefront and kiosk; `
|
|
+ 'history and the bin label stay, and it can be restored later.')) return
|
|
try {
|
|
await printedpartsApi.remove(item.value.printeditemid)
|
|
const response = await printedpartsApi.get(item.value.printeditemid)
|
|
item.value = response.data.data
|
|
} catch (retireError) {
|
|
console.error('Retire failed:', retireError)
|
|
}
|
|
}
|
|
|
|
async function restoreItem() {
|
|
try {
|
|
const response = await printedpartsApi.restore(item.value.printeditemid)
|
|
item.value = response.data.data
|
|
} catch (restoreError) {
|
|
console.error('Restore failed:', restoreError)
|
|
}
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-'
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hero-actions { margin-top: 0.75rem; }
|
|
.qty-out { color: var(--danger); }
|
|
.file-upload-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.file-revision-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
.file-revision-list li {
|
|
position: relative;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.45rem;
|
|
padding: 0.6rem 5.5rem 0.6rem 0.8rem;
|
|
}
|
|
.file-revision-list li.current-revision { border-color: var(--primary); }
|
|
.file-main {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.file-name {
|
|
font-weight: 600;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.file-meta {
|
|
color: var(--text-light);
|
|
font-size: 0.85rem;
|
|
margin-top: 0.2rem;
|
|
}
|
|
.file-delete {
|
|
position: absolute;
|
|
top: 0.55rem;
|
|
right: 0.6rem;
|
|
}
|
|
.qty-in { color: var(--success); }
|
|
</style>
|