453 lines
14 KiB
Vue
453 lines
14 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>{{ isEdit ? 'Edit Application' : 'New Application' }}</h2>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<form v-else @submit.prevent="saveApplication">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="appname">Application Name *</label>
|
|
<input
|
|
id="appname"
|
|
v-model="form.appname"
|
|
type="text"
|
|
class="form-control"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="supportteamid">Support Team</label>
|
|
<select
|
|
id="supportteamid"
|
|
v-model="form.supportteamid"
|
|
class="form-control"
|
|
>
|
|
<option value="">Select team...</option>
|
|
<option
|
|
v-for="team in supportTeams"
|
|
:key="team.supportteamid"
|
|
:value="team.supportteamid"
|
|
>
|
|
{{ team.teamname }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="appdescription">Description</label>
|
|
<textarea
|
|
id="appdescription"
|
|
v-model="form.appdescription"
|
|
class="form-control"
|
|
rows="2"
|
|
></textarea>
|
|
</div>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Application Flags</h4>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group checkbox-group">
|
|
<label>
|
|
<input type="checkbox" v-model="form.isinstallable" />
|
|
Installable
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" v-model="form.islicenced" />
|
|
Licensed
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" v-model="form.isprinter" />
|
|
Printer App
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" v-model="form.isrequired" />
|
|
Required on all PCs
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" v-model="form.ishidden" />
|
|
Hidden
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Links & Paths</h4>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="applicationlink">Application Link</label>
|
|
<input
|
|
id="applicationlink"
|
|
v-model="form.applicationlink"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="https://..."
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="documentationpath">Documentation Path</label>
|
|
<input
|
|
id="documentationpath"
|
|
v-model="form.documentationpath"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="URL or file path"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="installpath">Install Path</label>
|
|
<input
|
|
id="installpath"
|
|
v-model="form.installpath"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Network path or URL to install files"
|
|
/>
|
|
<small class="form-hint">
|
|
A share path or URL, or upload the installer below and this fills
|
|
itself in.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="packagefile">Installer</label>
|
|
<div v-if="uploadedPackage" class="upload-current">
|
|
<span class="upload-name">{{ uploadedPackage }}</span>
|
|
<button type="button" class="btn btn-sm btn-secondary" @click="removePackage"
|
|
:disabled="busyPackage">Remove installer</button>
|
|
</div>
|
|
<input id="packagefile" type="file" class="form-control"
|
|
:accept="PACKAGE_ACCEPT" @change="onPackagePicked" />
|
|
<div v-if="packageProgress !== null" class="upload-progress">
|
|
<div class="upload-bar"><span :style="{ width: packageProgress + '%' }"></span></div>
|
|
<span>{{ packageProgress }}%</span>
|
|
</div>
|
|
<small class="form-hint">
|
|
Up to 500MB. Anything larger belongs on the share - put its path in
|
|
Install Path instead. Downloads require a login.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="imagefile">Image</label>
|
|
<div v-if="imagePreview" class="upload-current">
|
|
<img :src="imagePreview" alt="" class="upload-thumb" />
|
|
<button type="button" class="btn btn-sm btn-secondary" @click="removeImage"
|
|
:disabled="busyImage">Remove image</button>
|
|
</div>
|
|
<input id="imagefile" type="file" class="form-control"
|
|
accept="image/*" @change="onImagePicked" />
|
|
<small class="form-hint">
|
|
PNG, JPG, GIF, WEBP, SVG or ICO. Replaces whatever is there now.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="image">Image path</label>
|
|
<input
|
|
id="image"
|
|
v-model="form.image"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="filled in by the upload above"
|
|
/>
|
|
<small class="form-hint">
|
|
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.
|
|
</small>
|
|
</div>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
|
|
|
|
<div class="form-group">
|
|
<label for="applicationnotes">Application Notes (HTML supported)</label>
|
|
<textarea
|
|
id="applicationnotes"
|
|
v-model="form.applicationnotes"
|
|
class="form-control"
|
|
rows="6"
|
|
placeholder="Enter notes... HTML tags like <BR>, <a>, <strong> are supported"
|
|
></textarea>
|
|
</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...' : 'Save Application' }}
|
|
</button>
|
|
<router-link to="/applications" 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 { applicationsApi, supportteamsApi } 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({
|
|
appname: '',
|
|
appdescription: '',
|
|
supportteamid: '',
|
|
isinstallable: false,
|
|
islicenced: false,
|
|
isprinter: false,
|
|
isrequired: false,
|
|
ishidden: false,
|
|
applicationlink: '',
|
|
documentationpath: '',
|
|
installpath: '',
|
|
image: '',
|
|
applicationnotes: ''
|
|
})
|
|
|
|
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
|
|
const teamsRes = await supportteamsApi.list()
|
|
supportTeams.value = teamsRes.data.data || []
|
|
|
|
// Load application if editing
|
|
if (isEdit.value) {
|
|
const response = await applicationsApi.get(route.params.id)
|
|
const app = response.data.data
|
|
|
|
form.value = {
|
|
appname: app.appname || '',
|
|
appdescription: app.appdescription || '',
|
|
supportteamid: app.supportteamid || '',
|
|
isinstallable: app.isinstallable || false,
|
|
islicenced: app.islicenced || false,
|
|
isprinter: app.isprinter || false,
|
|
isrequired: app.isrequired || false,
|
|
ishidden: app.ishidden || false,
|
|
applicationlink: app.applicationlink || '',
|
|
documentationpath: app.documentationpath || '',
|
|
installpath: app.installpath || '',
|
|
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)
|
|
error.value = 'Failed to load data'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
async function saveApplication() {
|
|
error.value = ''
|
|
saving.value = true
|
|
|
|
try {
|
|
const appData = {
|
|
appname: form.value.appname,
|
|
appdescription: form.value.appdescription || null,
|
|
supportteamid: form.value.supportteamid || null,
|
|
isinstallable: form.value.isinstallable,
|
|
islicenced: form.value.islicenced,
|
|
isprinter: form.value.isprinter,
|
|
isrequired: form.value.isrequired,
|
|
ishidden: form.value.ishidden,
|
|
applicationlink: form.value.applicationlink || null,
|
|
documentationpath: form.value.documentationpath || null,
|
|
installpath: form.value.installpath || null,
|
|
image: form.value.image || null,
|
|
applicationnotes: form.value.applicationnotes || null
|
|
}
|
|
|
|
let appid = route.params.id
|
|
if (isEdit.value) {
|
|
await applicationsApi.update(appid, appData)
|
|
} else {
|
|
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
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.upload-current {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.upload-thumb {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
object-fit: contain;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.25rem;
|
|
background: var(--bg);
|
|
}
|
|
.upload-name {
|
|
font-family: ui-monospace, monospace;
|
|
font-size: 0.85rem;
|
|
word-break: break-all;
|
|
}
|
|
.upload-progress {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.upload-bar {
|
|
flex: 1;
|
|
height: 0.5rem;
|
|
background: var(--border);
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
}
|
|
.upload-bar span {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--primary);
|
|
transition: width 0.2s ease;
|
|
}
|
|
|
|
.checkbox-group {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.checkbox-group label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.form-hint {
|
|
display: block;
|
|
margin-top: 0.25rem;
|
|
font-size: 0.8rem;
|
|
color: var(--text-light, #666);
|
|
}
|
|
</style>
|