ADR-013 Phase 4: relocate 4 self-contained plugin frontends
Relocate applications, geenforce, knowledgebase, and machines - each owns only its own views dir, so a clean move to plugins/<name>/frontend/ (views/ + routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its vitest spec move with it (ManifestEditor imports it as a sibling). Machinery fixes this batch surfaced: - routes.gen.js codegen uses namespace imports (import * as p_x). A route file without a `toplevel` export is undefined on the namespace instead of a strict- ESM missing-binding build error. - vitest gains a `pretest` stage so plugin-frontend specs (now under plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/. Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper) renders fully from its staged frontend. Build + 58 vitest + naming green.
This commit is contained in:
27
plugins/applications/frontend/routes.js
Normal file
27
plugins/applications/frontend/routes.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Applications routes (core feature)
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'applications',
|
||||
name: 'applications',
|
||||
component: () => import('./views/ApplicationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'applications/new',
|
||||
name: 'application-new',
|
||||
component: () => import('./views/ApplicationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'applications/:id',
|
||||
name: 'application-detail',
|
||||
component: () => import('./views/ApplicationDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'applications/:id/edit',
|
||||
name: 'application-edit',
|
||||
component: () => import('./views/ApplicationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
366
plugins/applications/frontend/views/ApplicationDetail.vue
Normal file
366
plugins/applications/frontend/views/ApplicationDetail.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Application Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="app">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="app.image">
|
||||
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="placeholder-icon">📦</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ app.appname }}</h1>
|
||||
</div>
|
||||
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||
<div class="hero-meta">
|
||||
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
|
||||
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
|
||||
</div>
|
||||
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
|
||||
<a v-if="app.applicationlink" :href="app.applicationlink" target="_blank" class="hero-link">
|
||||
<span class="link-icon">🔗</span> Launch Application
|
||||
</a>
|
||||
<a v-if="app.installpath" :href="app.installpath" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" class="hero-link">
|
||||
<span class="link-icon">📄</span> Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
<template v-if="contact.sso && contactEmailDomain">
|
||||
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
|
||||
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Notes -->
|
||||
<div class="section-card" v-if="app.applicationnotes">
|
||||
<h3 class="section-title">Application Notes</h3>
|
||||
<div class="notes-text">{{ app.applicationnotes }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="section-card" v-if="versions.length > 0">
|
||||
<h3 class="section-title">Available Versions</h3>
|
||||
<div class="version-list">
|
||||
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||
<span class="version-number">v{{ ver.version }}</span>
|
||||
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Knowledge Base -->
|
||||
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
|
||||
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
|
||||
<div class="kb-list">
|
||||
<a
|
||||
v-for="kb in app.knowledgebase"
|
||||
:key="kb.linkid"
|
||||
:href="kb.linkurl"
|
||||
target="_blank"
|
||||
class="kb-item"
|
||||
>
|
||||
<span class="kb-title">{{ kb.shortdescription }}</span>
|
||||
<span class="kb-keywords" v-if="kb.keywords">{{ kb.keywords }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Installed On PCs -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||
<div v-if="installedOn.length > 0" class="pc-list">
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.computerid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Not installed on any PCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { applicationsApi } from '@/api'
|
||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const app = ref(null)
|
||||
const versions = ref([])
|
||||
const installedOn = ref([])
|
||||
const contactEmailDomain = ref('')
|
||||
|
||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||
function contactEmail(contact) {
|
||||
return `${contact.sso}@${contactEmailDomain.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contactEmailDomain.value = await getContactEmailDomain()
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||
versions.value = versionsRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// Load installed on which PCs
|
||||
try {
|
||||
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||
installedOn.value = installedRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading application:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Hero description (app-specific) */
|
||||
.hero-description {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero links (app-specific) */
|
||||
.hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hero-link:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Placeholder image */
|
||||
.hero-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* PC List */
|
||||
.pc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pc-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.pc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pc-alias {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.pc-version {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||
.notes-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
279
plugins/applications/frontend/views/ApplicationForm.vue
Normal file
279
plugins/applications/frontend/views/ApplicationForm.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="image">Image Filename</label>
|
||||
<input
|
||||
id="image"
|
||||
v-model="form.image"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., myapp.png"
|
||||
/>
|
||||
<small class="form-hint">Image should be placed in /images/applications/</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([])
|
||||
|
||||
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 || ''
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await applicationsApi.update(route.params.id, appData)
|
||||
} else {
|
||||
await applicationsApi.create(appData)
|
||||
}
|
||||
|
||||
router.push('/applications')
|
||||
} catch (err) {
|
||||
console.error('Error saving application:', err)
|
||||
error.value = apiError(err, 'Failed to save application')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
213
plugins/applications/frontend/views/ApplicationsList.vue
Normal file
213
plugins/applications/frontend/views/ApplicationsList.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Applications</h2>
|
||||
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search applications..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="filter" class="form-control" @change="loadApplications">
|
||||
<option value="installable">Installable Applications</option>
|
||||
<option value="all">All Applications</option>
|
||||
<option value="hidden">Hidden Applications</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">Files</th>
|
||||
<th style="width: 50px;">Docs</th>
|
||||
<th>Application Name</th>
|
||||
<th>Description</th>
|
||||
<th>Support Team</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
|
||||
⬇
|
||||
</a>
|
||||
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
|
||||
🔗
|
||||
</a>
|
||||
</td>
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
|
||||
📄
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="`/applications/${app.appid}`">
|
||||
{{ app.appname }}
|
||||
</router-link>
|
||||
<span class="app-flags">
|
||||
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="description">{{ app.appdescription || '-' }}</td>
|
||||
<td>{{ app.supportteamname || '-' }}</td>
|
||||
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/applications/${app.appid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="applications.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No applications found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { applicationsApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
|
||||
const filter = ref('installable')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadApplications()
|
||||
})
|
||||
|
||||
async function loadApplications() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value,
|
||||
search: search.value || undefined
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
if (filter.value === 'installable') {
|
||||
params.installable = true
|
||||
} else if (filter.value === 'hidden') {
|
||||
params.hidden = true
|
||||
}
|
||||
|
||||
const response = await applicationsApi.list(params)
|
||||
applications.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading applications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadApplications()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadApplications()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadApplications()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application list specific styles */
|
||||
.icon-cell {
|
||||
text-align: center;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.icon-cell a {
|
||||
font-size: 1.25rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon-download {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.icon-docs {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.icon-cell a:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-flags {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user