The .kb-* classes had no styles, so the KB entries rendered as bare inline spans - shortdescription and keywords (both up to 500 chars) wrapped and mashed into one block. Style each entry as a bordered clickable card: description as the link title clamped to 2 lines, keywords split on whitespace into small muted chips below.
425 lines
12 KiB
Vue
425 lines
12 KiB
Vue
<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">
|
|
<span
|
|
v-for="(kw, i) in kbKeywords(kb.keywords)"
|
|
:key="i"
|
|
class="kb-tag"
|
|
>{{ kw }}</span>
|
|
</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'
|
|
}
|
|
|
|
// Keywords are stored space-separated; split into chips for the KB card.
|
|
function kbKeywords(keywords) {
|
|
return (keywords || '').split(/\s+/).filter(Boolean)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Application-specific styles - shared styles are in global style.css */
|
|
|
|
/* Knowledge Base card: each entry is a bordered block, not a run-on line.
|
|
shortdescription (up to 500 chars) clamps to 2 lines; keywords render as
|
|
small chips below instead of a second wall of text. */
|
|
.kb-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.kb-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg);
|
|
text-decoration: none;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
.kb-item:hover {
|
|
border-color: var(--primary);
|
|
background: var(--bg-card);
|
|
}
|
|
.kb-title {
|
|
color: var(--link);
|
|
font-weight: 600;
|
|
line-height: 1.35;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.kb-keywords {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
}
|
|
.kb-tag {
|
|
font-size: 0.72rem;
|
|
color: var(--text-light);
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* 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>
|