Make a retired asset findable and restorable from the product

The retire dialog says "History and relationships are kept, and it can be
restored." The API always kept that promise - ?active=false lists retired rows,
PUT {"isactive": true} restores one - and no screen in the product could reach
either. A retired asset simply vanished: the lists filtered it out, global
search missed it, and its detail page still rendered Edit and Retire with
nothing anywhere saying it was retired.

Five list pages gained a "Show retired" toggle that passes active=false, and a
retired row now carries a badge. Five detail pages gained a banner saying the
record is retired and what that means, a Restore button, and the suppression of
Edit and Retire while it is retired - offering Retire on an already-retired
record was the part that made the state invisible.

Verified end to end in a browser: retire a machine, the detail page says so and
offers Restore, the list hides it until the toggle is ticked and badges it when
shown, and Restore puts it back (isactive true again on the API).

One thing this does NOT do: global search still does not reach a retired asset.
That is a separate surface with its own filter, and worth deciding on
deliberately rather than sliding in here - a search that returns retired
records by default would change every result page.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 15:33:21 -04:00
parent 61a1c0b4fb
commit fb63ef9d30
9 changed files with 182 additions and 13 deletions

View File

@@ -10,11 +10,21 @@
<div class="header-actions"> <div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link> <router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link> <router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button> <button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link> <router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
</div> </div>
</div> </div>
<div v-if="computer && computer.isactive === false" class="retired-banner card">
<strong>This PC is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="computer"> <template v-else-if="computer">
@@ -644,6 +654,21 @@ async function confirmRestart() {
// Retire, not delete: the server deactivates the asset, so the PC // Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put. // leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true. // Reversible by an admin setting isactive back to true.
const isRetired = computed(() => computer.value?.isactive === false)
async function restore() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
try {
await computersApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() { async function confirmRetire() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC' const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. ` if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -660,6 +685,10 @@ async function confirmRetire() {
</script> </script>
<style scoped> <style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.portal-link { .portal-link {
font-size: 0.78rem; font-size: 0.78rem;
margin-left: 0.5rem; margin-left: 0.5rem;

View File

@@ -15,7 +15,14 @@
placeholder="Search computers..." placeholder="Search computers..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
</div> <!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadComputers" />
Show retired
</label>
</div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
@@ -106,6 +113,7 @@ function applySuggestion(word) {
setSearch(word) setSearch(word)
loadComputers() loadComputers()
} }
const showRetired = ref(false)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -123,6 +131,8 @@ async function loadComputers() {
perpage: perPage.value perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
const response = await computersApi.list(params) const response = await computersApi.list(params)
computers.value = response.data.data || [] computers.value = response.data.data || []

View File

@@ -9,14 +9,24 @@
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank"> <router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Label Print Label
</router-link> </router-link>
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine"> <router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine && !isRetired">
Edit Edit
</router-link> </router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button> <button @click="confirmRetire" class="btn btn-danger" v-if="machine && !isRetired">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live one,
with nothing on the page saying it was retired. Restoring it was
possible through the API and nowhere in the product. -->
<button @click="restore" class="btn btn-primary" v-if="machine && isRetired">Restore</button>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link> <router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div> </div>
</div> </div>
<div v-if="machine && isRetired" class="retired-banner card">
<strong>This machine is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine"> <template v-else-if="machine">
@@ -295,7 +305,7 @@ const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.ass
// relationships render via the shared AssetRelationships card // relationships render via the shared AssetRelationships card
onMounted(async () => { async function loadMachine() {
try { try {
const response = await machinesApi.get(route.params.id) const response = await machinesApi.get(route.params.id)
machine.value = response.data.data machine.value = response.data.data
@@ -305,7 +315,9 @@ onMounted(async () => {
} finally { } finally {
loading.value = false loading.value = false
} }
}) }
onMounted(loadMachine)
function getStatusClass(status) { function getStatusClass(status) {
if (!status) return 'badge-info' if (!status) return 'badge-info'
@@ -324,6 +336,20 @@ function formatDate(dateStr) {
// Retire, not delete: the server deactivates the asset, so the machine // Retire, not delete: the server deactivates the asset, so the machine
// leaves every list and report while its history and relationships stay put. // leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true. // Reversible by an admin setting isactive back to true.
const isRetired = computed(() => machine.value?.isactive === false)
async function restore() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
try {
await machinesApi.update(machine.value?.machine?.machineid, { isactive: true })
toast.success(`Restored ${label}`)
await loadMachine()
} catch (error) {
console.error('Error restoring machine:', error)
toast.error(apiError(error, 'Failed to restore machine'))
}
}
async function confirmRetire() { async function confirmRetire() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine' const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. ` if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -340,6 +366,10 @@ async function confirmRetire() {
</script> </script>
<style scoped> <style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.mono { .mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
} }

View File

@@ -15,6 +15,14 @@
placeholder="Search machines..." placeholder="Search machines..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise all along (?active=false
lists them, PUT isactive restores) but nothing in the UI could reach
it, so a retired machine was simply gone. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadMachines" />
Show retired
</label>
</div> </div>
<div class="card"> <div class="card">
@@ -41,6 +49,7 @@
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)"> @click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
<td> <td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template> {{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
<span v-if="item.isactive === false" class="badge badge-secondary">retired</span>
</td> </td>
<td>{{ item.name || '-' }}</td> <td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td> <td class="mono">{{ item.serialnumber || '-' }}</td>
@@ -122,6 +131,7 @@ function applySuggestion(word) {
// search page documents. Load directly. // search page documents. Load directly.
loadMachines() loadMachines()
} }
const showRetired = ref(false)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -139,6 +149,8 @@ async function loadMachines() {
perpage: perPage.value perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
const response = await machinesApi.list(params) const response = await machinesApi.list(params)
machines.value = response.data.data || [] machines.value = response.data.data || []

View File

@@ -9,11 +9,21 @@
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool"> <router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
Edit Edit
</router-link> </router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button> <button v-if="tool && !isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="tool && isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link> <router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
</div> </div>
</div> </div>
<div v-if="tool && tool.isactive === false" class="retired-banner card">
<strong>This measuring tool is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="tool"> <template v-else-if="tool">
@@ -181,7 +191,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api' import { measuringtoolsApi } from '@/api'
@@ -227,6 +237,21 @@ onMounted(async () => {
// Retire, not delete: the server deactivates the asset, so the measuring tool // Retire, not delete: the server deactivates the asset, so the measuring tool
// leaves every list and report while its history and relationships stay put. // leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true. // Reversible by an admin setting isactive back to true.
const isRetired = computed(() => tool.value?.isactive === false)
async function restore() {
const label = tool.value?.assetnumber || tool.value?.name || 'this tool'
try {
await measuringtoolsApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() { async function confirmRetire() {
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool' const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. ` if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -243,6 +268,10 @@ async function confirmRetire() {
</script> </script>
<style scoped> <style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.mono { .mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
} }

View File

@@ -28,7 +28,14 @@
<option value="current">Current</option> <option value="current">Current</option>
<option value="unknown">Unknown</option> <option value="unknown">Unknown</option>
</select> </select>
</div> <!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadTools" />
Show retired
</label>
</div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
@@ -115,6 +122,7 @@ function applySuggestion(word) {
} }
const typeid = ref('') const typeid = ref('')
const calibrationstatus = ref('') const calibrationstatus = ref('')
const showRetired = ref(false)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -139,6 +147,8 @@ async function loadTools() {
try { try {
const params = { page: page.value, perpage: perPage.value } const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
if (typeid.value) params.typeid = typeid.value if (typeid.value) params.typeid = typeid.value
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value

View File

@@ -45,7 +45,14 @@
{{ loc.locationname }} {{ loc.locationname }}
</option> </option>
</select> </select>
</div> <!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadDevices" />
Show retired
</label>
</div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
@@ -143,6 +150,7 @@ function applySuggestion(word) {
const selectedType = ref(null) const selectedType = ref(null)
const vendorFilter = ref('') const vendorFilter = ref('')
const locationFilter = ref('') const locationFilter = ref('')
const showRetired = ref(false)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(25) const perPage = ref(25)
const totalCount = ref(0) const totalCount = ref(0)
@@ -225,6 +233,8 @@ async function loadDevices() {
perpage: perPage.value perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
if (selectedType.value) params.typeid = selectedType.value if (selectedType.value) params.typeid = selectedType.value
if (vendorFilter.value) params.vendorid = vendorFilter.value if (vendorFilter.value) params.vendorid = vendorFilter.value
if (locationFilter.value) params.locationid = locationFilter.value if (locationFilter.value) params.locationid = locationFilter.value

View File

@@ -10,11 +10,21 @@
Print Label Print Label
</router-link> </router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link> <router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button> <button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link> <router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div> </div>
</div> </div>
<div v-if="printer && printer.isactive === false" class="retired-banner card">
<strong>This printer is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else-if="printer"> <template v-else-if="printer">
@@ -306,7 +316,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
@@ -406,6 +416,21 @@ function formatDate(dateStr) {
// Retire, not delete: the server deactivates the asset, so the printer // Retire, not delete: the server deactivates the asset, so the printer
// leaves every list and report while its history and relationships stay put. // leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true. // Reversible by an admin setting isactive back to true.
const isRetired = computed(() => printer.value?.isactive === false)
async function restore() {
const label = printer.value?.assetnumber || printer.value?.name || 'this printer'
try {
await printersApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() { async function confirmRetire() {
const label = displayTitle.value || 'this printer' const label = displayTitle.value || 'this printer'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. ` if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -422,6 +447,10 @@ async function confirmRetire() {
</script> </script>
<style scoped> <style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
/* Printer-specific styles - shared styles are in global style.css */ /* Printer-specific styles - shared styles are in global style.css */
.empty-state { .empty-state {

View File

@@ -25,7 +25,14 @@
{{ pt.printertype }} {{ pt.printertype }}
</option> </option>
</select> </select>
</div> <!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadPrinters" />
Show retired
</label>
</div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
@@ -111,6 +118,7 @@ function applySuggestion(word) {
// search page documents. Load directly. // search page documents. Load directly.
loadPrinters() loadPrinters()
} }
const showRetired = ref(false)
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -134,6 +142,8 @@ async function loadPrinters() {
perpage: perPage.value perpage: perPage.value
} }
if (search.value) params.search = search.value if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
if (typeFilter.value) params.typeid = typeFilter.value if (typeFilter.value) params.typeid = typeFilter.value
const response = await printersApi.list(params) const response = await printersApi.list(params)