Compare commits
2 Commits
lab-stage-
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b68e927ef6 | ||
|
|
6439d1ccd9 |
@@ -117,6 +117,12 @@ const routes = [
|
||||
component: () => import('../views/print/USBLabelBatch.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: '/print/printedparts-labels',
|
||||
name: 'print-printedparts-labels',
|
||||
component: () => import('../views/print/PrintedPartsLabels.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
|
||||
207
frontend/src/views/print/PrintedPartsLabels.vue
Normal file
207
frontend/src/views/print/PrintedPartsLabels.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
|
||||
<p>
|
||||
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of
|
||||
the item code, scannable at the parts kiosk.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="loading-msg">Loading parts...</div>
|
||||
<div v-else-if="items.length === 0" class="loading-msg">No parts found</div>
|
||||
<div v-else class="parts-grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.printeditemid"
|
||||
class="part-item"
|
||||
:class="{ selected: isSelected(item) }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(item)" @click.stop />
|
||||
<label>
|
||||
<strong><code>{{ item.itemcode }}</code></strong>
|
||||
<div class="alias">{{ item.itemname }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selectedItems.length }}</span> labels
|
||||
<label class="copies-label">Copies each:
|
||||
<input v-model.number="copies" type="number" min="1" max="10" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selectedItems.length === 0"
|
||||
@click="print">Print Labels</button>
|
||||
<button class="clear-btn" @click="selectedItems = []">Clear All</button>
|
||||
<button class="select-all-btn" @click="selectedItems = [...items]">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="labels-container">
|
||||
<div v-for="(label, index) in printLabels" :key="index" class="bin-label">
|
||||
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg>
|
||||
<div class="bin-code">{{ label.itemcode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { printedpartsApi } from '../../api'
|
||||
|
||||
const items = ref([])
|
||||
const selectedItems = ref([])
|
||||
const copies = ref(1)
|
||||
const loading = ref(true)
|
||||
const barcodeElements = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.list({ perpage: 500 })
|
||||
items.value = response.data.data || []
|
||||
// ?item=<id> preselects one part (the Detail-page print button)
|
||||
const preselect = new URLSearchParams(window.location.search).get('item')
|
||||
if (preselect) {
|
||||
const match = items.value.find(
|
||||
candidate => String(candidate.printeditemid) === preselect)
|
||||
if (match) selectedItems.value = [match]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading parts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const printLabels = computed(() => {
|
||||
const labels = []
|
||||
for (const item of selectedItems.value) {
|
||||
for (let copy = 0; copy < Math.max(1, copies.value); copy++) {
|
||||
labels.push(item)
|
||||
}
|
||||
}
|
||||
return labels
|
||||
})
|
||||
|
||||
function isSelected(item) {
|
||||
return selectedItems.value.some(
|
||||
candidate => candidate.printeditemid === item.printeditemid)
|
||||
}
|
||||
|
||||
function toggleItem(item) {
|
||||
if (isSelected(item)) {
|
||||
selectedItems.value = selectedItems.value.filter(
|
||||
candidate => candidate.printeditemid !== item.printeditemid)
|
||||
} else {
|
||||
selectedItems.value = [...selectedItems.value, item]
|
||||
}
|
||||
}
|
||||
|
||||
function setBarcodeElement(element, index) {
|
||||
if (element) barcodeElements.value[index] = element
|
||||
}
|
||||
|
||||
watch(printLabels, async labels => {
|
||||
await nextTick()
|
||||
labels.forEach((label, index) => {
|
||||
const element = barcodeElements.value[index]
|
||||
if (element) {
|
||||
// CODE128 of the short item code fits 1x0.5in with comfortable
|
||||
// scanner tolerance; a QR at this size would be marginal.
|
||||
JsBarcode(element, label.itemcode, {
|
||||
format: 'CODE128',
|
||||
displayValue: false,
|
||||
width: 1.4,
|
||||
height: 26,
|
||||
margin: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.controls {
|
||||
max-width: 46rem;
|
||||
margin: 1rem auto;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.parts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.part-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.part-item.selected { border-color: var(--primary); }
|
||||
.alias { color: var(--text-light); font-size: 0.85rem; }
|
||||
.selected-count { margin: 0.75rem 0; }
|
||||
.copies-label { margin-left: 1.25rem; }
|
||||
.copies-label input { width: 4rem; padding: 0.25rem; }
|
||||
.print-btn, .clear-btn, .select-all-btn {
|
||||
margin-right: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.loading-msg { color: var(--text-light); padding: 1rem; }
|
||||
|
||||
/* screen preview of the labels */
|
||||
.labels-container { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1rem; }
|
||||
.bin-label {
|
||||
width: 1in;
|
||||
height: 0.5in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
outline: 1px dashed #bbb;
|
||||
}
|
||||
.bin-barcode { width: 0.92in; height: 0.3in; }
|
||||
.bin-code {
|
||||
font-size: 6.5pt;
|
||||
font-family: monospace;
|
||||
color: #000;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 1in x 0.5in roll stock: one label per page */
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.labels-container { display: block; padding: 0; gap: 0; }
|
||||
.bin-label {
|
||||
outline: none;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page { size: 1in 0.5in; margin: 0; }
|
||||
body { margin: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,8 @@
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
||||
<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">
|
||||
@@ -131,6 +136,7 @@ function debouncedSearch() {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.lowstock-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -346,3 +346,113 @@ def kiosk_take():
|
||||
_ledger_write(item, 'take', -quantity, sso, name)
|
||||
return success_response(item.to_dict(),
|
||||
message=f'Took {quantity}, {item.quantityonhand} left')
|
||||
|
||||
|
||||
# --- reports (merged into GET /api/reports while the plugin is enabled) ------
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from flask import Response
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
def _csv_response(rows, columns, filename):
|
||||
"""CSV download; local helper because generate_csv is not on the
|
||||
contract surface (shopdb.api)."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(columns)
|
||||
for row in rows:
|
||||
writer.writerow([row.get(column, '') for column in columns])
|
||||
return Response(
|
||||
output.getvalue(), mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename={filename}'})
|
||||
|
||||
|
||||
@printedparts_bp.route('/reports/stock', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def report_stock():
|
||||
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
|
||||
|
||||
ledgerdelta should always be 0; anything else means a write path
|
||||
bypassed the single-commit rule and needs finding.
|
||||
"""
|
||||
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
|
||||
# string and breaks the delta arithmetic's type.
|
||||
ledger = {itemid: int(total) for itemid, total in
|
||||
db.session.query(
|
||||
PrintedItemTransaction.printeditemid,
|
||||
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
|
||||
.group_by(PrintedItemTransaction.printeditemid).all()}
|
||||
rows = []
|
||||
for item in PrintedItem.query.filter_by(isactive=True).order_by(
|
||||
PrintedItem.itemname).all():
|
||||
rows.append({
|
||||
'itemcode': item.itemcode,
|
||||
'itemname': item.itemname,
|
||||
'binlocation': item.binlocation or '',
|
||||
'quantityonhand': item.quantityonhand,
|
||||
'lowstockthreshold': item.lowstockthreshold,
|
||||
'islowstock': item.islowstock,
|
||||
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
|
||||
})
|
||||
columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand',
|
||||
'lowstockthreshold', 'islowstock', 'ledgerdelta']
|
||||
if request.args.get('format') == 'csv':
|
||||
return _csv_response(rows, columns, 'printedparts-stock.csv')
|
||||
return success_response({'columns': columns, 'rows': rows})
|
||||
|
||||
|
||||
@printedparts_bp.route('/reports/consumption', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def report_consumption():
|
||||
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
query = (db.session.query(
|
||||
PrintedItem.itemcode,
|
||||
PrintedItem.itemname,
|
||||
func.count(PrintedItemTransaction.transactionid),
|
||||
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
|
||||
.join(PrintedItemTransaction,
|
||||
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
|
||||
.filter(PrintedItemTransaction.transactiontype == 'take'))
|
||||
if days > 0:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
||||
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
|
||||
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
|
||||
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
|
||||
'quantitytaken': int(taken)}
|
||||
for code, name, takes, taken in query.all()]
|
||||
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
|
||||
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
|
||||
if request.args.get('format') == 'csv':
|
||||
return _csv_response(rows, columns, 'printedparts-consumption.csv')
|
||||
return success_response({'columns': columns, 'rows': rows, 'days': days})
|
||||
|
||||
|
||||
@printedparts_bp.route('/reports/by-person', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def report_by_person():
|
||||
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
query = (db.session.query(
|
||||
PrintedItemTransaction.employeesso,
|
||||
func.max(PrintedItemTransaction.employeename),
|
||||
func.count(PrintedItemTransaction.transactionid),
|
||||
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
|
||||
.filter(PrintedItemTransaction.transactiontype == 'take'))
|
||||
if days > 0:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
||||
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
|
||||
query = query.group_by(PrintedItemTransaction.employeesso)
|
||||
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
|
||||
'quantitytaken': int(taken)}
|
||||
for sso, name, takes, taken in query.all()]
|
||||
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
|
||||
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
|
||||
if request.args.get('format') == 'csv':
|
||||
return _csv_response(rows, columns, 'printedparts-by-person.csv')
|
||||
return success_response({'columns': columns, 'rows': rows, 'days': days})
|
||||
|
||||
@@ -62,6 +62,32 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'printedparts'),
|
||||
]
|
||||
|
||||
def get_reports(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
'id': 'printedparts-stock',
|
||||
'name': '3D Parts Stock',
|
||||
'description': 'Stock levels with low-stock flags and the '
|
||||
'cache-vs-ledger reconcile check',
|
||||
'category': 'inventory',
|
||||
'endpoint': '/api/printedparts/reports/stock',
|
||||
},
|
||||
{
|
||||
'id': 'printedparts-consumption',
|
||||
'name': '3D Parts Consumption',
|
||||
'description': 'Takes per item over a date range',
|
||||
'category': 'usage',
|
||||
'endpoint': '/api/printedparts/reports/consumption',
|
||||
},
|
||||
{
|
||||
'id': 'printedparts-by-person',
|
||||
'name': '3D Parts by Person',
|
||||
'description': 'Takes grouped by employee',
|
||||
'category': 'usage',
|
||||
'endpoint': '/api/printedparts/reports/by-person',
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user