A provider lookup answers whether a unit is covered. It does not produce the invoice or the extended-warranty certificate, and a manually entered warranty had nowhere to keep one - so the proof stayed in somebody's mailbox until they left. Two columns rather than one: the served URL of the stored document, and the name the vendor sent it under, because "Dell invoice 4471.pdf" is what a person recognises a year later and "warranty-12.pdf" is not. The download route sends the original name back. Authenticated in both directions, unlike an asset photo: an invoice carries pricing and a service tag. One document per warranty, replacing any prior extension so a re-upload as .pdf does not leave the old .png behind claiming to be current. Capped at 25MB - a certificate is a document, not a disk image. Office formats are allowed because purchase records genuinely arrive as .msg and .xlsx, not only as PDFs.
496 lines
19 KiB
Vue
496 lines
19 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Warranties</h2>
|
|
<div class="header-actions">
|
|
<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">
|
|
<option value="">All</option>
|
|
<option value="active">Active</option>
|
|
<option value="expiring">Expiring Soon</option>
|
|
<option value="expired">Expired</option>
|
|
<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">
|
|
<div v-if="loading" class="muted">Loading...</div>
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Vendor</th>
|
|
<th>Status</th>
|
|
<th>Service Level</th>
|
|
<th>Ends</th>
|
|
<th>Covers</th>
|
|
<th>Machine #</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<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 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>
|
|
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip" :title="a.name || a.assetnumber">{{ a.assetnumber }}</router-link>
|
|
</td>
|
|
<td>
|
|
<template v-for="a in w.assets" :key="`m-${a.assetid}`">
|
|
<MachineMapChip v-if="a.machine" :machine="a.machine" />
|
|
</template>
|
|
<span v-if="!w.assets.some(a => a.machine)" class="muted">-</span>
|
|
</td>
|
|
<td class="actions">
|
|
<button v-if="w.provider !== 'manual'" class="btn btn-secondary btn-sm" @click="refresh(w)">Refresh</button>
|
|
<button class="btn btn-secondary btn-sm" @click="openModal(w)">Edit</button>
|
|
<button class="btn btn-danger btn-sm" @click="deleteWarranty(w)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
<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>
|
|
|
|
<div v-if="showModal" class="modal-overlay">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Warranty</h3></div>
|
|
<form @submit.prevent="save">
|
|
<div class="modal-body">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Vendor *</label>
|
|
<select v-model="form.vendor" class="form-control" required>
|
|
<option value="" disabled>Select a vendor...</option>
|
|
<option v-for="name in vendorOptions" :key="name" :value="name">{{ name }}</option>
|
|
</select>
|
|
<small class="muted">Who backs the warranty. Manage the list under Vendors.</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Lookup source</label>
|
|
<select v-model="form.provider" class="form-control">
|
|
<option value="manual">Manual entry</option>
|
|
<option value="dell">Dell (auto-refresh)</option>
|
|
<option value="lenovo">Lenovo (auto-refresh)</option>
|
|
<option value="hp">HP (auto-refresh)</option>
|
|
</select>
|
|
<small class="muted">Where coverage data comes from. Non-manual sources can be refreshed from the maker's warranty API.</small>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Service Tag / Serial</label>
|
|
<input v-model="form.servicetag" type="text" class="form-control" maxlength="100" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Service Level</label>
|
|
<input v-model="form.servicelevel" type="text" class="form-control" maxlength="150" />
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Start Date</label>
|
|
<input v-model="form.startdate" type="date" class="form-control" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>End Date</label>
|
|
<input v-model="form.enddate" type="date" class="form-control" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Covered Assets</label>
|
|
<div class="asset-search">
|
|
<input v-model="assetQuery" type="text" class="form-control" placeholder="Search asset number or name..."
|
|
@input="searchAssets" />
|
|
<ul v-if="assetResults.length" class="asset-results">
|
|
<li v-for="a in assetResults" :key="a.assetid" @click="addAsset(a)">
|
|
{{ a.assetnumber }}<span v-if="a.name" class="muted"> - {{ a.name }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="asset-chips">
|
|
<span v-for="a in selectedAssets" :key="a.assetid" class="asset-chip removable">
|
|
{{ a.assetnumber }}
|
|
<button type="button" @click="removeAsset(a)">x</button>
|
|
</span>
|
|
<span v-if="!selectedAssets.length" class="muted">None linked</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Notes</label>
|
|
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Proof of cover</label>
|
|
<div v-if="proofName" class="proof-current">
|
|
<a v-if="proofUrl" :href="proofUrl" @click.prevent="downloadProof">{{ proofName }}</a>
|
|
<span v-else>{{ proofName }}</span>
|
|
<button type="button" class="btn btn-sm btn-secondary" @click="removeProof"
|
|
:disabled="busyProof">Remove</button>
|
|
</div>
|
|
<input type="file" class="form-control" :accept="PROOF_ACCEPT" @change="onProofPicked" />
|
|
<small class="muted">
|
|
Invoice, certificate or a saved email - up to 25MB. Downloading
|
|
needs a login, since it carries pricing and a service tag.
|
|
</small>
|
|
</div>
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { colorStyle } from '@/utils/colorStyle'
|
|
import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
import MachineMapChip from '../components/MachineMapChip.vue'
|
|
|
|
const toast = useToast()
|
|
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)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
const form = ref(blankForm())
|
|
const selectedAssets = ref([])
|
|
const assetQuery = ref('')
|
|
const assetResults = ref([])
|
|
// Vendor dropdown options, pulled from the site vendor catalog.
|
|
const vendorNames = ref([])
|
|
// Keep an existing warranty's vendor selectable even if it is not (or no longer)
|
|
// in the catalog, so editing never silently blanks it.
|
|
const vendorOptions = computed(() => {
|
|
const current = (form.value.vendor || '').trim()
|
|
if (current && !vendorNames.value.includes(current)) return [current, ...vendorNames.value]
|
|
return vendorNames.value
|
|
})
|
|
|
|
function blankForm() {
|
|
return { vendor: '', provider: 'manual', servicetag: '', servicelevel: '', startdate: '', enddate: '', notes: '' }
|
|
}
|
|
|
|
function statusLabel(status) {
|
|
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
|
|
}
|
|
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
|
|
|
// Route to the right detail page by asset type.
|
|
function assetLink(a) {
|
|
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/', measuring_tool: '/measuringtools/by-asset/' }
|
|
const base = map[a.assettypename] || '/assets/'
|
|
return base + a.assetid
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadData()
|
|
loadVendors()
|
|
// Deep-link from an asset detail page: open the add modal pre-linked to it.
|
|
const addfor = route.query.addfor
|
|
if (addfor) {
|
|
openModal()
|
|
try {
|
|
const response = await assetsApi.get(addfor)
|
|
const asset = response.data.data
|
|
if (asset) selectedAssets.value = [{ assetid: asset.assetid, assetnumber: asset.assetnumber }]
|
|
} catch (err) {
|
|
selectedAssets.value = [{ assetid: Number(addfor), assetnumber: `Asset ${addfor}` }]
|
|
}
|
|
}
|
|
})
|
|
|
|
async function loadVendors() {
|
|
// Best-effort: the combobox still accepts free text if this fails.
|
|
try {
|
|
const response = await vendorsApi.list({ per_page: 1000 })
|
|
const rows = response.data.data
|
|
const list = Array.isArray(rows) ? rows : (rows.items || [])
|
|
vendorNames.value = list.map(v => v.vendor || v.name).filter(Boolean).sort()
|
|
} catch (err) {
|
|
vendorNames.value = []
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const params = statusFilter.value ? { status: statusFilter.value } : {}
|
|
const response = await warrantyApi.list(params)
|
|
items.value = response.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading warranties:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
let searchTimer = null
|
|
function searchAssets() {
|
|
clearTimeout(searchTimer)
|
|
const q = assetQuery.value.trim()
|
|
if (!q) { assetResults.value = []; return }
|
|
searchTimer = setTimeout(async () => {
|
|
try {
|
|
const response = await assetsApi.search(q, { perpage: 8 })
|
|
assetResults.value = response.data.data || []
|
|
} catch (err) {
|
|
assetResults.value = []
|
|
}
|
|
}, 250)
|
|
}
|
|
|
|
function addAsset(a) {
|
|
if (!selectedAssets.value.some(x => x.assetid === a.assetid)) {
|
|
selectedAssets.value.push({ assetid: a.assetid, assetnumber: a.assetnumber })
|
|
}
|
|
assetQuery.value = ''
|
|
assetResults.value = []
|
|
}
|
|
function removeAsset(a) {
|
|
selectedAssets.value = selectedAssets.value.filter(x => x.assetid !== a.assetid)
|
|
}
|
|
|
|
function openModal(item = null) {
|
|
editing.value = item
|
|
if (item) {
|
|
form.value = {
|
|
vendor: item.vendor || '', provider: item.provider || 'manual',
|
|
servicetag: item.servicetag || '', servicelevel: item.servicelevel || '',
|
|
startdate: item.startdate || '', enddate: item.enddate || '', notes: item.notes || '',
|
|
}
|
|
selectedAssets.value = (item.assets || []).map(a => ({ assetid: a.assetid, assetnumber: a.assetnumber }))
|
|
} else {
|
|
form.value = blankForm()
|
|
selectedAssets.value = []
|
|
proofName.value = ''
|
|
proofUrl.value = ''
|
|
}
|
|
pendingProof.value = null
|
|
assetQuery.value = ''
|
|
assetResults.value = []
|
|
error.value = ''
|
|
showModal.value = true
|
|
}
|
|
function closeModal() { showModal.value = false; editing.value = null }
|
|
|
|
// Chosen before a new warranty exists, so it is held here and uploaded once
|
|
// there is an id to attach it to.
|
|
const pendingProof = ref(null)
|
|
const proofName = ref('')
|
|
const proofUrl = ref('')
|
|
const busyProof = ref(false)
|
|
const PROOF_ACCEPT = '.pdf,.png,.jpg,.jpeg,.gif,.webp,.tif,.tiff,.msg,.eml,.doc,.docx,.xls,.xlsx'
|
|
const MAX_PROOF_BYTES = 25 * 1024 * 1024
|
|
|
|
function onProofPicked(event) {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
if (file.size > MAX_PROOF_BYTES) {
|
|
error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 25MB.`
|
|
event.target.value = ''
|
|
return
|
|
}
|
|
error.value = ''
|
|
pendingProof.value = file
|
|
proofName.value = file.name
|
|
proofUrl.value = ''
|
|
}
|
|
|
|
async function removeProof() {
|
|
pendingProof.value = null
|
|
proofName.value = ''
|
|
proofUrl.value = ''
|
|
if (editing.value) {
|
|
busyProof.value = true
|
|
try { await warrantyApi.removeProof(editing.value.warrantyid) }
|
|
catch (err) { error.value = apiError(err, 'Failed to remove the document') }
|
|
finally { busyProof.value = false }
|
|
}
|
|
}
|
|
|
|
// The route is authenticated, so a plain href would 401. Fetch it through the
|
|
// client, which carries the token, then hand the blob to the browser.
|
|
async function downloadProof() {
|
|
try {
|
|
const response = await api.get(proofUrl.value, { responseType: 'blob' })
|
|
const url = URL.createObjectURL(response.data)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = proofName.value || 'proof'
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to download the document')
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
error.value = ''
|
|
saving.value = true
|
|
try {
|
|
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
|
|
let warrantyid
|
|
if (editing.value) {
|
|
warrantyid = editing.value.warrantyid
|
|
await warrantyApi.update(warrantyid, payload)
|
|
} else {
|
|
const created = await warrantyApi.create(payload)
|
|
warrantyid = created.data.data.warrantyid
|
|
}
|
|
// After the save: a new warranty has no id until it exists, and the upload
|
|
// keys on it. A failed upload leaves the saved record alone and reports.
|
|
if (pendingProof.value) {
|
|
await warrantyApi.uploadProof(warrantyid, pendingProof.value)
|
|
pendingProof.value = null
|
|
}
|
|
closeModal()
|
|
loadData()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to save')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function deleteWarranty(w) {
|
|
if (!confirm(`Delete the ${w.vendor} warranty?`)) return
|
|
try {
|
|
await warrantyApi.remove(w.warrantyid)
|
|
loadData()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to delete'))
|
|
}
|
|
}
|
|
|
|
async function syncDell(all = false) {
|
|
syncing.value = true
|
|
toast.info(all ? 'Re-checking all Dell warranties...' : 'Looking up Dell service tags... this can take a moment.')
|
|
try {
|
|
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).`)
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Dell sync failed'))
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
async function refresh(w) {
|
|
try {
|
|
const response = await warrantyApi.refresh(w.warrantyid)
|
|
loadData()
|
|
const updated = response.data?.data
|
|
if (updated) {
|
|
toast.success(`${updated.vendor}: ${updated.servicelevel || 'coverage'} ends ${updated.enddate || 'unknown'}`)
|
|
}
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Refresh failed'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<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; }
|
|
/* Keep the "Status" label + its select on one line so it aligns with the
|
|
single-line search box next to it. */
|
|
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
|
.filters .form-control { max-width: 320px; }
|
|
.result-count { color: var(--text-light); font-size: 0.85rem; }
|
|
.proof-current { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
|
.proof-current a { word-break: break-all; }
|
|
.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; }
|
|
.asset-results {
|
|
position: absolute; z-index: 10; left: 0; right: 0; margin: 2px 0 0;
|
|
padding: 0; list-style: none; background: var(--bg-card-solid);
|
|
border: 1px solid var(--border); border-radius: 6px; max-height: 200px; overflow-y: auto;
|
|
}
|
|
.asset-results li { padding: 0.45rem 0.6rem; cursor: pointer; }
|
|
.asset-results li:hover { background: var(--bg); }
|
|
.asset-chips { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
|
|
.asset-chip {
|
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
|
padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
|
|
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
|
|
}
|
|
.asset-chip.removable button {
|
|
border: none; background: none; color: var(--text-light); cursor: pointer; font-size: 0.85rem; padding: 0;
|
|
}
|
|
</style>
|