Attach proof of cover to a warranty

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.
This commit is contained in:
cproudlock
2026-08-12 11:45:40 -04:00
parent c28b02e45b
commit 2fce81f33f
5 changed files with 369 additions and 4 deletions

View File

@@ -151,6 +151,21 @@
<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">
@@ -167,7 +182,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi, vendorsApi } from '@/api'
import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import MachineMapChip from '../components/MachineMapChip.vue'
@@ -315,7 +330,10 @@ function openModal(item = null) {
} else {
form.value = blankForm()
selectedAssets.value = []
proofName.value = ''
proofUrl.value = ''
}
pendingProof.value = null
assetQuery.value = ''
assetResults.value = []
error.value = ''
@@ -323,15 +341,75 @@ function openModal(item = null) {
}
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) {
await warrantyApi.update(editing.value.warrantyid, payload)
warrantyid = editing.value.warrantyid
await warrantyApi.update(warrantyid, payload)
} else {
await warrantyApi.create(payload)
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()
@@ -390,6 +468,8 @@ async function refresh(w) {
.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; }