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.
157 lines
4.4 KiB
Vue
157 lines
4.4 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>3D Printed Parts</h2>
|
|
<div class="header-actions">
|
|
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
|
|
Print Labels
|
|
</router-link>
|
|
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search code, name, description, bin..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<label class="lowstock-filter">
|
|
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
|
Low stock only
|
|
</label>
|
|
<label class="lowstock-filter">
|
|
<input v-model="includeRetired" type="checkbox" @change="loadItems" />
|
|
Include retired
|
|
</label>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th>Code</th>
|
|
<th>Name</th>
|
|
<th>Quantity</th>
|
|
<th>Bin</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="item in items"
|
|
:key="item.printeditemid"
|
|
class="clickable-row"
|
|
@click="$router.push(`/printedparts/${item.printeditemid}`)"
|
|
>
|
|
<td class="thumb-cell">
|
|
<img
|
|
v-if="item.imageurl"
|
|
:src="withBase(item.imageurl)"
|
|
:alt="item.itemname"
|
|
class="item-thumb"
|
|
/>
|
|
</td>
|
|
<td>{{ item.itemcode || '-' }}</td>
|
|
<td>
|
|
{{ item.itemname }}
|
|
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
|
</td>
|
|
<td>
|
|
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
|
{{ item.quantityonhand }}
|
|
</span>
|
|
</td>
|
|
<td>{{ item.binlocation || '-' }}</td>
|
|
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
|
|
</tr>
|
|
<tr v-if="items.length === 0">
|
|
<td colspan="6" class="empty-state">No printed parts found</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<PaginationBar
|
|
:page="page"
|
|
:total-pages="totalPages"
|
|
@change="setPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { printedpartsApi } from '@/api'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
import { withBase } from '@/utils/basePath'
|
|
|
|
const items = ref([])
|
|
const loading = ref(true)
|
|
const lowstockOnly = ref(false)
|
|
const includeRetired = ref(false)
|
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(loadItems)
|
|
|
|
async function loadItems() {
|
|
loading.value = true
|
|
try {
|
|
const params = { page: page.value, perpage: perPage.value }
|
|
if (search.value) params.search = search.value
|
|
if (lowstockOnly.value) params.lowstock = 'true'
|
|
if (includeRetired.value) params.active = 'false'
|
|
const response = await printedpartsApi.list(params)
|
|
items.value = response.data.data || []
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
|
} catch (error) {
|
|
console.error('Error loading printed parts:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => setSearch(search.value), 300)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.item-thumb {
|
|
width: 2.2rem;
|
|
height: 2.2rem;
|
|
object-fit: cover;
|
|
border-radius: 0.25rem;
|
|
}
|
|
.thumb-cell { width: 3rem; }
|
|
.truncate-cell {
|
|
max-width: 20rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.header-actions { display: flex; gap: 0.5rem; }
|
|
.lowstock-filter {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
color: var(--text-light);
|
|
cursor: pointer;
|
|
}
|
|
</style>
|