From eab225e1e62fbf94a33f5468dee85542ddac41f3 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:35:31 -0400 Subject: [PATCH] printedparts stage 14: retire/restore in the UI, dashless item codes Retire button with confirmation on the detail page (item leaves the storefront and the kiosk rejects its code; ledger history and label survive), Restore on retired items, and an Include-retired list toggle with a badge. Restore is its own permission-gated POST - the generic update still cannot flip isactive. New codes mint as WJRP0042 style without the dash; existing codes are immutable bin labels and keep their form. --- docs/PLUGIN-LAB-PRINTEDPARTS.md | 12 +++++++++ frontend/src/api/index.js | 3 +++ .../views/printedparts/PrintedItemDetail.vue | 27 +++++++++++++++++++ .../views/printedparts/PrintedItemsList.vue | 11 +++++++- .../views/settings/PrintedPartsSettings.vue | 2 +- plugins/printedparts/api/routes.py | 16 ++++++++++- .../test_plugins/test_printedparts_ledger.py | 22 ++++++++++++++- 7 files changed, 89 insertions(+), 4 deletions(-) diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 75b53a9..ebf576d 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -335,6 +335,18 @@ docs-drift guard again). 4. Test: active user's email + free-text merge deduped, inactive user skipped (`test_alert_recipients_merge_users_and_freetext`). +## Stage 14 (extension) - retire/restore in the UI, dashless codes + +Field feedback stage: the soft-delete endpoint existed with no button, and +the site wanted `WJRP0042`, not `WJRP-0042`. +1. Detail gains Retire (confirm dialog; item leaves the storefront and the + kiosk 404s its code, history and label intact) and Restore; the list + gains an Include-retired toggle (`?active=false`) with a Retired badge. + Restore is its own POST gated by printedparts.delete - PUT deliberately + cannot flip isactive. +2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their + codes - itemcode is an immutable label once printed on a bin. + --- ## Where each pattern lives (cheat sheet) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2a00d2b..87b4529 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1144,6 +1144,9 @@ export const printedpartsApi = { remove(printeditemid) { return api.delete(`/printedparts/items/${printeditemid}`) }, + restore(printeditemid) { + return api.post(`/printedparts/items/${printeditemid}/restore`) + }, uploadImage(printeditemid, file) { const formData = new FormData() formData.append('file', file) diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue index 1bd805e..ceef6bf 100644 --- a/frontend/src/views/printedparts/PrintedItemDetail.vue +++ b/frontend/src/views/printedparts/PrintedItemDetail.vue @@ -14,6 +14,7 @@ {{ item.quantityonhand }} on hand Low stock + Retired

{{ item.itemdescription }}

@@ -29,6 +30,10 @@ class="btn btn-secondary btn-sm">Edit Bin Label + +
@@ -197,6 +202,28 @@ async function submitLedger() { } } +async function retireItem() { + if (!window.confirm( + `Retire ${item.value.itemname}? It leaves the storefront and kiosk; ` + + 'history and the bin label stay, and it can be restored later.')) return + try { + await printedpartsApi.remove(item.value.printeditemid) + const response = await printedpartsApi.get(item.value.printeditemid) + item.value = response.data.data + } catch (retireError) { + console.error('Retire failed:', retireError) + } +} + +async function restoreItem() { + try { + const response = await printedpartsApi.restore(item.value.printeditemid) + item.value = response.data.data + } catch (restoreError) { + console.error('Restore failed:', restoreError) + } +} + function formatDate(value) { if (!value) return '-' return new Date(value).toLocaleString() diff --git a/frontend/src/views/printedparts/PrintedItemsList.vue b/frontend/src/views/printedparts/PrintedItemsList.vue index e17f700..09a0c46 100644 --- a/frontend/src/views/printedparts/PrintedItemsList.vue +++ b/frontend/src/views/printedparts/PrintedItemsList.vue @@ -22,6 +22,10 @@ Low stock only +
@@ -56,7 +60,10 @@ /> {{ item.itemcode || '-' }} - {{ item.itemname }} + + {{ item.itemname }} + Retired + {{ item.quantityonhand }} @@ -92,6 +99,7 @@ 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) @@ -106,6 +114,7 @@ async function loadItems() { 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 diff --git a/frontend/src/views/settings/PrintedPartsSettings.vue b/frontend/src/views/settings/PrintedPartsSettings.vue index d06096e..f4e1efb 100644 --- a/frontend/src/views/settings/PrintedPartsSettings.vue +++ b/frontend/src/views/settings/PrintedPartsSettings.vue @@ -13,7 +13,7 @@

- New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}-0042. + New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042. Changing it does not rename existing items.

diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index f44f3bf..409827d 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -91,7 +91,7 @@ def _imagedir(): def _mint_itemcode(item): """Set itemcode from the configured prefix + the flushed row id.""" prefix = Setting.get('printedparts_code_prefix') or '3DP' - item.itemcode = f'{prefix}-{item.printeditemid:04d}' + item.itemcode = f'{prefix}{item.printeditemid:04d}' @printedparts_bp.route('/items', methods=['POST']) @@ -159,6 +159,20 @@ def delete_item(item_id: int): return success_response(message='Printed item retired') +@printedparts_bp.route('/items//restore', methods=['POST']) +@jwt_required() +@require_permission('printedparts.delete') +def restore_item(item_id: int): + """Bring a retired item back; code, photo, and history are intact.""" + 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) + item.isactive = True + db.session.commit() + return success_response(item.to_dict(), message='Printed item restored') + + # --- item image: the models.py upload/serve/delete trio --------------------- @printedparts_bp.route('/items//image', methods=['POST']) diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py index 7835216..ce07b4b 100644 --- a/tests/test_plugins/test_printedparts_ledger.py +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -39,7 +39,7 @@ def test_create_mints_itemcode(client, auth_headers): headers=auth_headers) assert response.status_code == 201 data = response.get_json()['data'] - assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}" + assert data['itemcode'] == f"3DP{data['printeditemid']:04d}" assert data['quantityonhand'] == 0 @@ -240,3 +240,23 @@ def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app, assert take.status_code == 200 # 4 on hand: crossed threshold 5 assert captured['to'] == ['lead@site.test', 'extra@site.test'] + + +def test_retire_hides_and_restore_returns(client, auth_headers, item): + """Retire drops the item from the default list and the kiosk; restore + brings it back with history intact.""" + assert client.delete(f'/api/printedparts/items/{item}', + headers=auth_headers).status_code == 200 + + listed = client.get('/api/printedparts/items').get_json()['data'] + assert all(row['printeditemid'] != item for row in listed) + kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001') + assert kiosk.status_code == 404 + + including = client.get('/api/printedparts/items?active=false') + assert any(row['printeditemid'] == item + for row in including.get_json()['data']) + + assert client.post(f'/api/printedparts/items/{item}/restore', + headers=auth_headers).status_code == 200 + assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200