printedparts stage 3: read API + list page (first visible win)
GET /items (paginated, search across code/name/description/bin, lowstock filter) and GET /items/<id> with recent transactions, both open reads. printedpartsApi client, router file repointed at the renamed views, PrintedItemsList with image thumbs and a red/green quantity badge against the per-item threshold. Nav entry '3D Parts' with a new 'box' Lucide icon mapping (the sidebar renders nothing for unknown icon names - lab gotcha).
This commit is contained in:
@@ -1126,3 +1126,13 @@ export const measuringtoolsApi = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3D printed parts (printedparts plugin)
|
||||||
|
export const printedpartsApi = {
|
||||||
|
list(params = {}) {
|
||||||
|
return api.get('/printedparts/items', { params })
|
||||||
|
},
|
||||||
|
get(printeditemid) {
|
||||||
|
return api.get(`/printedparts/items/${printeditemid}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,25 +11,25 @@ export default [
|
|||||||
{
|
{
|
||||||
path: 'printedparts',
|
path: 'printedparts',
|
||||||
name: 'printedparts',
|
name: 'printedparts',
|
||||||
component: () => import('../../views/printedparts/PrintedpartsList.vue'),
|
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
||||||
meta: { plugin: 'printedparts' }
|
meta: { plugin: 'printedparts' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'printedparts/new',
|
path: 'printedparts/new',
|
||||||
name: 'printedparts-new',
|
name: 'printedparts-new',
|
||||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'printedparts/:id',
|
path: 'printedparts/:id',
|
||||||
name: 'printedparts-detail',
|
name: 'printedparts-detail',
|
||||||
component: () => import('../../views/printedparts/PrintedpartsDetail.vue'),
|
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
||||||
meta: { plugin: 'printedparts' }
|
meta: { plugin: 'printedparts' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'printedparts/:id/edit',
|
path: 'printedparts/:id/edit',
|
||||||
name: 'printedparts-edit',
|
name: 'printedparts-edit',
|
||||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ import ToastHost from '../components/ToastHost.vue'
|
|||||||
import {
|
import {
|
||||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||||
KeyRound, LogOut
|
Box, KeyRound, LogOut
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||||
@@ -147,6 +147,7 @@ const iconMap = {
|
|||||||
'image': Image,
|
'image': Image,
|
||||||
'shield': ShieldCheck,
|
'shield': ShieldCheck,
|
||||||
'ruler': Ruler,
|
'ruler': Ruler,
|
||||||
|
'box': Box,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default navigation (used as fallback if API fails)
|
// Default navigation (used as fallback if API fails)
|
||||||
|
|||||||
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>3D Printed Parts</h2>
|
||||||
|
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
.lowstock-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="page-header">
|
|
||||||
<h2>Printedparts</h2>
|
|
||||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Printedparts</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filters">
|
|
||||||
<input
|
|
||||||
v-model="search"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Search..."
|
|
||||||
@input="debouncedSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Asset Tag</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Example Field</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in items" :key="item.assetid">
|
|
||||||
<td>{{ item.assetnumber || '-' }}</td>
|
|
||||||
<td>{{ item.name || '-' }}</td>
|
|
||||||
<td>{{ item.examplefield || '-' }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<router-link
|
|
||||||
:to="`/printedparts/${item.assetid}`"
|
|
||||||
class="btn btn-secondary btn-sm"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</router-link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="items.length === 0">
|
|
||||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
|
||||||
No printedparts records found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PaginationBar
|
|
||||||
:page="page"
|
|
||||||
:totalPages="totalPages"
|
|
||||||
:perPage="perPage"
|
|
||||||
@update:page="goToPage"
|
|
||||||
@update:perPage="changePerPage"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import api from '../../api'
|
|
||||||
import PaginationBar from '../../components/PaginationBar.vue'
|
|
||||||
|
|
||||||
// local api client. move into src/api/index.js (see
|
|
||||||
// plugins/printedparts/frontend-api-snippet.js) then swap for:
|
|
||||||
// import { printedpartsApi } from '../../api'
|
|
||||||
const printedpartsApi = {
|
|
||||||
list(params = {}) { return api.get('/printedparts', { params }) },
|
|
||||||
get(itemId) { return api.get(`/printedparts/${itemId}`) },
|
|
||||||
create(data) { return api.post('/printedparts', data) },
|
|
||||||
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
|
|
||||||
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = ref([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const search = ref('')
|
|
||||||
const page = ref(1)
|
|
||||||
const totalPages = ref(1)
|
|
||||||
const perPage = ref(25)
|
|
||||||
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
|
|
||||||
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 printedparts:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function debouncedSearch() {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
page.value = 1
|
|
||||||
loadItems()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPage(target) {
|
|
||||||
if (target >= 1 && target <= totalPages.value) {
|
|
||||||
page.value = target
|
|
||||||
loadItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePerPage(newPerPage) {
|
|
||||||
perPage.value = newPerPage
|
|
||||||
page.value = 1
|
|
||||||
loadItems()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters .form-control {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,18 +1,66 @@
|
|||||||
"""Printedparts plugin API routes.
|
"""Printedparts plugin API routes.
|
||||||
|
|
||||||
Stage 2 placeholder: the blueprint must import cleanly for plugin discovery
|
Reads are open (jwt optional) like every list surface; mutations arrive in
|
||||||
and migrations (the alembic env imports the models package, which pulls in
|
later stages with permission gates. The kiosk endpoints (unauthenticated by
|
||||||
plugin.py and this module). Real endpoints land in the next stage.
|
explicit decision - see the proposal) also land later.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint, request
|
||||||
|
from flask_jwt_extended import jwt_required
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from shopdb.api import success_response
|
from shopdb.api import (
|
||||||
|
db,
|
||||||
|
success_response,
|
||||||
|
error_response,
|
||||||
|
paginated_response,
|
||||||
|
ErrorCodes,
|
||||||
|
get_pagination_params,
|
||||||
|
paginate_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..models import PrintedItem
|
||||||
|
|
||||||
printedparts_bp = Blueprint('printedparts', __name__)
|
printedparts_bp = Blueprint('printedparts', __name__)
|
||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/ping', methods=['GET'])
|
@printedparts_bp.route('/items', methods=['GET'])
|
||||||
def ping():
|
@jwt_required(optional=True)
|
||||||
"""Liveness probe for the lab: proves the blueprint is registered."""
|
def list_items():
|
||||||
return success_response({'plugin': 'printedparts', 'status': 'ok'})
|
"""List printed items, paginated; search + low-stock filter."""
|
||||||
|
page, per_page = get_pagination_params(request)
|
||||||
|
query = PrintedItem.query
|
||||||
|
if request.args.get('active', 'true').lower() != 'false':
|
||||||
|
query = query.filter(PrintedItem.isactive == True)
|
||||||
|
if search := request.args.get('search'):
|
||||||
|
like = f'%{search}%'
|
||||||
|
query = query.filter(or_(
|
||||||
|
PrintedItem.itemcode.ilike(like),
|
||||||
|
PrintedItem.itemname.ilike(like),
|
||||||
|
PrintedItem.itemdescription.ilike(like),
|
||||||
|
PrintedItem.binlocation.ilike(like),
|
||||||
|
))
|
||||||
|
if request.args.get('lowstock', '').lower() == 'true':
|
||||||
|
query = query.filter(
|
||||||
|
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
|
||||||
|
query = query.order_by(PrintedItem.itemname)
|
||||||
|
items, total = paginate_query(query, page, per_page)
|
||||||
|
return paginated_response(
|
||||||
|
[item.to_dict() for item in items], page, per_page, total)
|
||||||
|
|
||||||
|
|
||||||
|
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def get_item(item_id: int):
|
||||||
|
"""Get one printed item with its recent transactions."""
|
||||||
|
item = db.session.get(PrintedItem, item_id)
|
||||||
|
if not item:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Printed item {item_id} not found',
|
||||||
|
http_code=404)
|
||||||
|
data = item.to_dict()
|
||||||
|
recent = (item.transactions
|
||||||
|
.order_by(db.desc('transactiondate'))
|
||||||
|
.limit(25).all())
|
||||||
|
data['recenttransactions'] = [t.to_dict() for t in recent]
|
||||||
|
return success_response(data)
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ class PrintedpartsPlugin(BasePlugin):
|
|||||||
def init_app(self, app: Flask, db_instance) -> None:
|
def init_app(self, app: Flask, db_instance) -> None:
|
||||||
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
||||||
|
|
||||||
|
def get_navigation_items(self) -> List[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'name': '3D Parts',
|
||||||
|
'icon': 'box',
|
||||||
|
'route': '/printedparts',
|
||||||
|
'position': 46,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def on_install(self, app: Flask) -> None:
|
def on_install(self, app: Flask) -> None:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
self._seed_settings()
|
self._seed_settings()
|
||||||
|
|||||||
Reference in New Issue
Block a user