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

@@ -1,166 +1,167 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Article' : 'Add Knowledge Base Article' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveArticle">
<div class="form-group">
<label for="shortdescription">Description *</label>
<input
id="shortdescription"
v-model="form.shortdescription"
type="text"
class="form-control"
required
maxlength="500"
placeholder="Brief description of the article"
/>
</div>
<div class="form-group">
<label for="linkurl">URL *</label>
<input
id="linkurl"
v-model="form.linkurl"
type="url"
class="form-control"
required
maxlength="2000"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="keywords">Keywords</label>
<input
id="keywords"
v-model="form.keywords"
type="text"
class="form-control"
maxlength="500"
placeholder="Space-separated keywords"
/>
<small class="form-hint">Keywords help with search - separate with spaces</small>
</div>
<div class="form-group">
<label for="appid">Topic (Application)</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
>
<option value="">-- Select Topic (Optional) --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Select the application/topic this article relates to</small>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update Article' : 'Add Article') }}
</button>
<router-link to="/knowledgebase" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { knowledgebaseApi, applicationsApi } from '../../api'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const form = ref({
shortdescription: '',
linkurl: '',
keywords: '',
appid: ''
})
const applications = ref([])
onMounted(async () => {
try {
// Load applications for topic dropdown
const appsRes = await applicationsApi.list({ perpage: 1000 })
applications.value = appsRes.data.data || []
// Load article if editing
if (isEdit.value) {
const response = await knowledgebaseApi.get(route.params.id)
const article = response.data.data
form.value = {
shortdescription: article.shortdescription || '',
linkurl: article.linkurl || '',
keywords: article.keywords || '',
appid: article.application?.appid || ''
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveArticle() {
error.value = ''
saving.value = true
try {
const articleData = {
shortdescription: form.value.shortdescription,
linkurl: form.value.linkurl,
keywords: form.value.keywords || null,
appid: form.value.appid || null
}
if (isEdit.value) {
await knowledgebaseApi.update(route.params.id, articleData)
} else {
await knowledgebaseApi.create(articleData)
}
router.push('/knowledgebase')
} catch (err) {
console.error('Error saving article:', err)
error.value = err.response?.data?.message || 'Failed to save article'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light, #666);
}
</style>
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Article' : 'Add Knowledge Base Article' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveArticle">
<div class="form-group">
<label for="shortdescription">Description *</label>
<input
id="shortdescription"
v-model="form.shortdescription"
type="text"
class="form-control"
required
maxlength="500"
placeholder="Brief description of the article"
/>
</div>
<div class="form-group">
<label for="linkurl">URL *</label>
<input
id="linkurl"
v-model="form.linkurl"
type="url"
class="form-control"
required
maxlength="2000"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="keywords">Keywords</label>
<input
id="keywords"
v-model="form.keywords"
type="text"
class="form-control"
maxlength="500"
placeholder="Space-separated keywords"
/>
<small class="form-hint">Keywords help with search - separate with spaces</small>
</div>
<div class="form-group">
<label for="appid">Topic (Application)</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
>
<option value="">-- Select Topic (Optional) --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Select the application/topic this article relates to</small>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update Article' : 'Add Article') }}
</button>
<router-link to="/knowledgebase" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { knowledgebaseApi, applicationsApi } from '../../api'
import { apiError } from '../../utils/apiError'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const form = ref({
shortdescription: '',
linkurl: '',
keywords: '',
appid: ''
})
const applications = ref([])
onMounted(async () => {
try {
// Load applications for topic dropdown
const appsRes = await applicationsApi.list({ perpage: 1000 })
applications.value = appsRes.data.data || []
// Load article if editing
if (isEdit.value) {
const response = await knowledgebaseApi.get(route.params.id)
const article = response.data.data
form.value = {
shortdescription: article.shortdescription || '',
linkurl: article.linkurl || '',
keywords: article.keywords || '',
appid: article.application?.appid || ''
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveArticle() {
error.value = ''
saving.value = true
try {
const articleData = {
shortdescription: form.value.shortdescription,
linkurl: form.value.linkurl,
keywords: form.value.keywords || null,
appid: form.value.appid || null
}
if (isEdit.value) {
await knowledgebaseApi.update(route.params.id, articleData)
} else {
await knowledgebaseApi.create(articleData)
}
router.push('/knowledgebase')
} catch (err) {
console.error('Error saving article:', err)
error.value = apiError(err, 'Failed to save article')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light, #666);
}
</style>