Files
shopdb-flask/plugins/printedparts/frontend/views/PrintedItemsList.vue
cproudlock 85ff25462e
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Reset to page one when a filter changes, and let the catalog carry a real type
Two unrelated things found while looking at blank printer types.

Selecting a filter while past page one returned an empty list. The filter asked
the server for page 5 of a result set that now had one page, and the screen said
nothing matched. useListQuery already resets the page - setSearch and setExtra
both do - but the filter dropdowns bypassed it and called the loader directly.
Nine list pages now route through applyFilter, which calls setPage(1) when it
needs to and loads directly when already on page one, so the composable's URL
watcher does not also fire and fetch twice.

scripts/retype_models.py addresses why printer types cannot be derived. The
catalog types every printer model "Printer": true, and useless, since it does not
say whether the product is a laser, a plotter or a label printer. That answer is
a property of the model - every VersaLink C405 is a laser MFP - but nothing
recorded it, so nothing could derive it. Recording it on the MODEL means the
existing backfill fills every printer by exact name match, and a printer added
later inherits the right type the moment its model is chosen.

It exports the models needing a decision to CSV with a type suggested from the
model number, a person corrects the column, and applying it is a dry run unless
given --commit. A suggested type is refused unless it already exists in that
asset class's own vocabulary, which is what keeps the later name match working.

The suggestion order matters and got this wrong first time: a generic plotter
pattern matched "Zebra ZT411" and filed a label printer as a plotter. Brands now
come before generic patterns, and the review step exists precisely because a
confident wrong guess would type every asset using that model.

Verified on the development database: 24 printer models need a decision, 22 got
a sensible suggestion, applying them let all 42 printers match a printertype by
name, and the transaction rolled back cleanly.
2026-08-05 11:26:23 -04:00

170 lines
5.0 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="applyFilter" />
Low stock only
</label>
<label class="lowstock-filter">
<input v-model="includeRetired" type="checkbox" @change="applyFilter" />
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>Gage Lab Tag</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.gagelabtag || 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)
// A filter change must go back to page 1. Selecting a filter while on page 5
// asked the server for page 5 of a result set that now has one page, and the
// list came back empty as though the filter matched nothing.
//
// setPage(1) writes the URL, which the composable's watcher picks up and
// answers with onChange - so calling the loader as well would fetch twice.
// Load directly only when already on page 1, where nothing changes and the
// watcher stays silent.
function applyFilter() {
if (page.value > 1) setPage(1)
else 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>