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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user