Files
shopdb-flask/plugins/printedparts/frontend/views/PrintedItemDetail.vue
cproudlock af9a3b190b ADR-013 Phase 4: frontend staging machinery + relocate printedparts; fix router crash
The staging step that makes lean per-site frontend builds possible, plus the
first plugin relocated as the pilot.

- scripts/stage-frontend.mjs: copies each chosen plugin's plugins/<name>/frontend/
  into frontend/src/.plugins-staged/<name>/ and codegens routes.gen.js. Plugin
  selection via SITE_PLUGINS (comma-separated); empty = all plugins that have a
  frontend/ (the full build). Wired as npm predev/prebuild; outputs gitignored.
- Router imports routes.gen.js and merges staged routes with the in-tree
  ./routes/*.js glob - dual-location during the transition.
- printedparts relocated: its 6 views (list/detail/form/kiosk + the settings and
  labels views from the shared dirs) moved into plugins/printedparts/frontend/
  views/, core imports rewritten to the @/ alias; routes.js is the self-contained
  route module. Its old in-tree route file is removed.

Also fixes a crash the previous commit (37c764b) shipped: slides.js exports only
`toplevel` (its child routes live in core.js), so the router's
flatMap(m => m.default) produced an undefined child and threw
"Cannot read properties of undefined (reading 'path')" at load - the whole SPA
went blank. Guarded with `m.default || []`. (The earlier "print pages are blank"
reading was this crash, not page nature.)

Verified live: /machines renders again; the relocated /printedparts list renders
identically from the staged plugin frontend; SITE_PLUGINS=machines excludes
printedparts from routes.gen. Build (via npm, runs stage) + vitest + naming green.
2026-07-18 23:42:43 -04:00

376 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">Bin 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>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.employeename || transaction.employeesso }}</td>
<td>{{ transaction.reason || '-' }}</td>
</tr>
<tr v-if="!item.recenttransactions?.length">
<td colspan="5" 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'
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
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>