New public print view at /print/printedparts-labels following the plugin-owned USB label precedent: multi-select with per-item copies, CODE128 of the item code via JsBarcode (a QR at this size is at the edge of scanner tolerance), one label per page on 1in x 0.5in roll stock via a new @page size. The Detail page's Bin Label button preselects its item through ?item=<id>; the list header gains a batch Print Labels button.
148 lines
4.1 KiB
Vue
148 lines
4.1 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>
|
|
</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 }}</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 { 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'
|
|
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>
|