diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index b488b84..d95d150 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -306,6 +306,9 @@ export const printersApi = { getDrivers(id) { return api.get(`/printers/${id}/drivers`) }, + supplyForecast(days = 90) { + return api.get('/printers/supplies/forecast', { params: { days } }) + }, lowSupplies() { return api.get('/printers/lowsupplies') }, @@ -477,6 +480,33 @@ export const applicationsApi = { delete(id) { return api.delete(`/applications/${id}`) }, + // multipart image upload; backend sets image to the served URL + uploadImage(id, file) { + const form = new FormData() + form.append('file', file) + return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) + }, + removeImage(id) { + return api.delete(`/applications/${id}/image`) + }, + // installer upload; backend sets installpath to the download URL. + // onProgress gets 0-100 - an installer is big enough that a silent wait + // reads as a hang. + uploadPackage(id, file, onProgress) { + const form = new FormData() + form.append('file', file) + return api.post(`/applications/${id}/package`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: event => { + if (onProgress && event.total) { + onProgress(Math.round((event.loaded * 100) / event.total)) + } + } + }) + }, + removePackage(id) { + return api.delete(`/applications/${id}/package`) + }, // Versions getVersions(appId) { return api.get(`/applications/${appId}/versions`) @@ -1088,6 +1118,15 @@ export const warrantyApi = { }, report() { return api.get('/warranty/report') + }, + // proof of cover: invoice, certificate, whatever the vendor sent + uploadProof(id, file) { + const form = new FormData() + form.append('file', file) + return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) + }, + removeProof(id) { + return api.delete(`/warranty/${id}/proof`) } } diff --git a/plugins/applications/frontend/views/ApplicationDetail.vue b/plugins/applications/frontend/views/ApplicationDetail.vue index 833df5c..88fb573 100644 --- a/plugins/applications/frontend/views/ApplicationDetail.vue +++ b/plugins/applications/frontend/views/ApplicationDetail.vue @@ -1,441 +1,452 @@ - - - - - + + + + + diff --git a/plugins/applications/frontend/views/ApplicationForm.vue b/plugins/applications/frontend/views/ApplicationForm.vue index d6fc1a6..5a7736a 100644 --- a/plugins/applications/frontend/views/ApplicationForm.vue +++ b/plugins/applications/frontend/views/ApplicationForm.vue @@ -111,18 +111,59 @@ class="form-control" placeholder="Network path or URL to install files" /> + + A share path or URL, or upload the installer below and this fills + itself in. +
- + +
+ {{ uploadedPackage }} + +
+ +
+
+ {{ packageProgress }}% +
+ + Up to 500MB. Anything larger belongs on the share - put its path in + Install Path instead. Downloads require a login. + +
+ +
+ +
+ + +
+ + + PNG, JPG, GIF, WEBP, SVG or ICO. Replaces whatever is there now. + +
+ +
+ - Image should be placed in /images/applications/ + + Set by the upload. Older entries hold a bare filename served from + /images/applications/, and those still work - leave them alone + unless you are replacing the image. +

Notes

@@ -184,6 +225,76 @@ const form = ref({ const supportTeams = ref([]) +// Files are chosen before the application exists, so they are held here and +// uploaded once there is an id to attach them to (see saveApplication). +const pendingImage = ref(null) +const pendingPackage = ref(null) +const imagePreview = ref('') +const uploadedPackage = ref('') +const packageProgress = ref(null) +const busyImage = ref(false) +const busyPackage = ref(false) + +const PACKAGE_ACCEPT = '.exe,.msi,.msp,.zip,.7z,.cab,.iso,.appx,.msix,.ps1,.bat,.txt,.pdf' +const MAX_PACKAGE_BYTES = 500 * 1024 * 1024 + +function onImagePicked(event) { + const file = event.target.files?.[0] + if (!file) return + pendingImage.value = file + // Show the chosen file immediately rather than after a round trip. + imagePreview.value = URL.createObjectURL(file) +} + +function onPackagePicked(event) { + const file = event.target.files?.[0] + if (!file) return + if (file.size > MAX_PACKAGE_BYTES) { + error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 500MB. ` + + 'Put it on the share and use Install Path instead.' + event.target.value = '' + return + } + error.value = '' + pendingPackage.value = file + uploadedPackage.value = file.name +} + +async function removeImage() { + pendingImage.value = null + imagePreview.value = '' + form.value.image = '' + if (isEdit.value) { + busyImage.value = true + try { await applicationsApi.removeImage(route.params.id) } + catch (err) { error.value = apiError(err, 'Failed to remove the image') } + finally { busyImage.value = false } + } +} + +async function removePackage() { + pendingPackage.value = null + uploadedPackage.value = '' + if (isEdit.value) { + busyPackage.value = true + try { + const response = await applicationsApi.removePackage(route.params.id) + form.value.installpath = response.data.data.installpath || '' + } catch (err) { + error.value = apiError(err, 'Failed to remove the installer') + } finally { busyPackage.value = false } + } +} + +// The image field holds either a served URL (new) or a bare filename from the +// classic site, which is still rendered out of /images/applications/. +function imageSrcFor(value) { + if (!value) return '' + return value.startsWith('/api/') || value.startsWith('http') + ? value + : `/images/applications/${value}` +} + onMounted(async () => { try { // Load support teams @@ -210,6 +321,10 @@ onMounted(async () => { image: app.image || '', applicationnotes: app.applicationnotes || '' } + imagePreview.value = imageSrcFor(app.image) + if ((app.installpath || '').startsWith('/api/applications/package/')) { + uploadedPackage.value = app.installpath.split('/').pop() + } } } catch (err) { console.error('Error loading data:', err) @@ -240,16 +355,34 @@ async function saveApplication() { applicationnotes: form.value.applicationnotes || null } + let appid = route.params.id if (isEdit.value) { - await applicationsApi.update(route.params.id, appData) + await applicationsApi.update(appid, appData) } else { - await applicationsApi.create(appData) + const created = await applicationsApi.create(appData) + appid = created.data.data.appid + } + + // Uploads come after the save: a new application has no id until it exists, + // and both endpoints key on it. A failed upload must not lose the record + // that saved fine, so it reports and stays on the form. + if (pendingImage.value) { + await applicationsApi.uploadImage(appid, pendingImage.value) + pendingImage.value = null + } + if (pendingPackage.value) { + packageProgress.value = 0 + await applicationsApi.uploadPackage(appid, pendingPackage.value, + percent => { packageProgress.value = percent }) + pendingPackage.value = null + packageProgress.value = null } router.push('/applications') } catch (err) { console.error('Error saving application:', err) error.value = apiError(err, 'Failed to save application') + packageProgress.value = null } finally { saving.value = false } @@ -257,6 +390,46 @@ async function saveApplication() {