Warranty polish: hero badges everywhere, search/pagination, re-check, shared apiError

- Shared utils/apiError.js reads the correct nested error message (with
  fallbacks); swept 37 views/components off the shallow path so real backend
  messages (e.g. in-use 409s) surface instead of generic "Failed".
- useWarrantyBadge composable: warranty hero status/end-date badge now on PC,
  equipment, printer, and network detail heroes (one shared fetch feeds the
  badge + the WarrantyPanel).
- Warranties list: client-side search (vendor/level/tag/asset) + pagination;
  truncate long service levels so they stop blowing out the table width.
- "Re-check all" button + POST /warranty/sync/dell?all=true to re-pull dated
  Dell warranties, not just missing ones.
- Deprecate the standalone pxe-images/warranty_sync.py in favor of the plugin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 17:23:21 -04:00
parent 640b8de1b2
commit ec2de635ed
46 changed files with 4091 additions and 3968 deletions

View File

@@ -3,14 +3,17 @@
<div class="page-header">
<h2>Warranties</h2>
<div class="header-actions">
<button class="btn btn-secondary" :disabled="syncing" @click="syncDell">
<button class="btn btn-secondary" :disabled="syncing" @click="syncDell(false)">
{{ syncing ? 'Syncing Dell...' : 'Sync Dell' }}
</button>
<button class="btn btn-secondary" :disabled="syncing" @click="syncDell(true)"
title="Re-check every Dell warranty, including ones already dated">
Re-check all
</button>
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div>
</div>
<div class="filters">
<label>Status
<select v-model="statusFilter" class="form-control" @change="loadData">
@@ -21,6 +24,8 @@
<option value="unknown">Unknown</option>
</select>
</label>
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset..." />
<span class="result-count">{{ filteredItems.length }} of {{ items.length }}</span>
</div>
<div class="card">
@@ -39,10 +44,10 @@
</tr>
</thead>
<tbody>
<tr v-for="w in items" :key="w.warrantyid">
<tr v-for="w in paginatedItems" :key="w.warrantyid">
<td><strong>{{ w.vendor }}</strong><span v-if="w.provider !== 'manual'" class="muted"> ({{ w.provider }})</span></td>
<td><span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span></td>
<td>{{ w.servicelevel || '-' }}</td>
<td class="servicelevel-cell" :title="w.servicelevel">{{ w.servicelevel || '-' }}</td>
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<span v-if="!w.assets.length" class="muted">-</span>
@@ -54,12 +59,18 @@
<button class="btn btn-danger btn-sm" @click="deleteWarranty(w)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<tr v-if="filteredItems.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No warranties</td>
</tr>
</tbody>
</table>
</div>
<div v-if="totalPages > 1" class="pagination">
<button class="btn btn-secondary btn-sm" :disabled="page === 1" @click="page--">Prev</button>
<span class="page-info">Page {{ page }} of {{ totalPages }}</span>
<button class="btn btn-secondary btn-sm" :disabled="page === totalPages" @click="page++">Next</button>
</div>
</template>
</div>
@@ -141,11 +152,12 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const route = useRoute()
@@ -153,6 +165,28 @@ const route = useRoute()
const items = ref([])
const loading = ref(true)
const syncing = ref(false)
const search = ref('')
const page = ref(1)
const perPage = 25
// Client-side search across vendor / service level / tag / linked asset numbers.
const filteredItems = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return items.value
return items.value.filter(w =>
(w.vendor || '').toLowerCase().includes(term) ||
(w.servicelevel || '').toLowerCase().includes(term) ||
(w.servicetag || '').toLowerCase().includes(term) ||
(w.assets || []).some(a => (a.assetnumber || '').toLowerCase().includes(term)))
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / perPage)))
const paginatedItems = computed(() => filteredItems.value.slice((page.value - 1) * perPage, page.value * perPage))
// Reset to page 1 whenever the filtered set changes.
watch([filteredItems, () => filteredItems.value.length], () => {
if (page.value > totalPages.value) page.value = 1
})
watch(search, () => { page.value = 1 })
const statusFilter = ref('')
const showModal = ref(false)
const editing = ref(null)
@@ -254,14 +288,6 @@ function openModal(item = null) {
}
function closeModal() { showModal.value = false; editing.value = null }
// API errors nest the message at data.data.error.message (see error_response).
function apiError(err, fallback) {
return err.response?.data?.data?.error?.message
|| err.response?.data?.error?.message
|| err.response?.data?.message
|| fallback
}
async function save() {
error.value = ''
saving.value = true
@@ -291,11 +317,11 @@ async function deleteWarranty(w) {
}
}
async function syncDell() {
async function syncDell(all = false) {
syncing.value = true
toast.info('Looking up Dell service tags... this can take a moment.')
toast.info(all ? 'Re-checking all Dell warranties...' : 'Looking up Dell service tags... this can take a moment.')
try {
const response = await warrantyApi.syncDell()
const response = await warrantyApi.syncDell(all)
const summary = response.data?.data || {}
loadData()
toast.success(`Dell sync: ${summary.created || 0} added, ${summary.updated || 0} updated (${summary.matched || 0} tags matched).`)
@@ -323,6 +349,12 @@ async function refresh(w) {
<style scoped>
.muted { color: var(--text-light); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
.filters { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.filters .form-control { max-width: 320px; }
.result-count { color: var(--text-light); font-size: 0.85rem; }
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
.page-info { color: var(--text-light); font-size: 0.85rem; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.asset-search { position: relative; }