Files
shopdb-flask/frontend/src/views/AppLayout.vue
cproudlock 26b6b6b32f printedparts: Parts Kiosk link in the sidebar Displays section
Beside Shopfloor Dashboard and TV Slideshow, opening in a new tab and
shown only while the plugin is enabled - kiosk-style pages get
launched from the Displays group, not the Information nav.
2026-07-17 08:38:57 -04:00

283 lines
10 KiB
Vue

<template>
<div class="app-layout">
<aside class="sidebar">
<div class="sidebar-header">
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
<h1>{{ facilityName }}</h1>
</div>
<div class="sidebar-search">
<input
v-model="searchQuery"
type="text"
placeholder="Search..."
@keyup.enter="performSearch"
/>
</div>
<nav class="sidebar-nav">
<template v-for="item in navItems" :key="item.route">
<div v-if="item.section" class="nav-section">{{ item.section }}</div>
<router-link :to="item.route">
<component v-if="item.iconComponent" :is="item.iconComponent" :size="16" />
{{ item.name }}
</router-link>
</template>
<div class="nav-section">Displays</div>
<a :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
<a :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
<a v-if="isPluginEnabled('printedparts')" :href="withBase('/parts-kiosk')"
target="_blank" class="external-link">Parts Kiosk</a>
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
</nav>
<div class="sidebar-footer">
<button class="theme-toggle" @click="toggleTheme" :title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
<span v-if="currentTheme === 'dark'"><Sun :size="14" /> Light</span>
<span v-else><Moon :size="14" /> Dark</span>
</button>
<div class="user-menu">
<template v-if="authStore.isAuthenticated">
<div class="user-identity">
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
:alt="authStore.displayName" @error="onAvatarError" />
<div class="user-ident">
<div class="username">{{ authStore.displayName }}</div>
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
</div>
</div>
<div class="user-actions">
<router-link to="/change-password" class="icon-btn" title="Change password">
<KeyRound :size="16" /> Password
</router-link>
<button class="icon-btn" @click="handleLogout" title="Log out">
<LogOut :size="16" /> Logout
</button>
</div>
</template>
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
</div>
</div>
</aside>
<main class="main-content">
<div v-if="activeNotifications.length" class="notification-banner">
<div
v-for="n in activeNotifications"
:key="n.notificationid"
class="notification-item"
:class="getNotificationType(n)"
>
<span class="notification-text">{{ n.notification }}</span>
<span class="notification-meta">
<span v-if="n.starttime" class="notification-date">{{ formatDate(n.starttime) }}</span>
<template v-if="n.ticketnumber">
<a
v-if="getTicketSearchUrl(n.ticketnumber)"
:href="getTicketSearchUrl(n.ticketnumber)"
target="_blank"
class="notification-ticket"
>{{ n.ticketnumber }}</a>
<span v-else class="notification-ticket">{{ n.ticketnumber }}</span>
</template>
</span>
</div>
</div>
<!-- Keyed on path so same-component navigation (machine -> machine via
a relationship link) remounts and reloads; query-only changes
(e.g. /reports?report=x) do not remount, and the settings shell
keys as one unit so its rail survives child navigation. -->
<router-view :key="routeViewKey" />
</main>
<ToastHost />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
Box, KeyRound, LogOut
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi, notificationsApi } from '../api'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
import { withBase } from '../utils/basePath'
import { isPluginEnabled } from '../composables/enabledPlugins'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const routeViewKey = computed(() =>
route.path.startsWith('/settings') ? '/settings' : route.path
)
const searchQuery = ref('')
const navItems = ref([])
const activeNotifications = ref([])
const facilityName = ref('ShopDB')
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
const servicenowConfig = ref({ enabled: true, searchUrl: '' })
function getTicketSearchUrl(ticketnumber) {
// Null when ServiceNow is disabled or no search template is set: the
// ticket number renders as plain text instead of a link.
const config = servicenowConfig.value
if (!ticketnumber || !config.enabled || !config.searchUrl) return null
return config.searchUrl.replace('{ticket}', encodeURIComponent(ticketnumber))
}
// Map backend icon names to Lucide components
const iconMap = {
'layout-dashboard': LayoutDashboard,
'calendar': Calendar,
'map': Map,
'cog': Cog,
'desktop': Monitor,
'printer': Printer,
'network-wired': Globe,
'usb': Usb,
'bell': Bell,
'app-window': AppWindow,
'book-open': BookOpen,
'bar-chart-3': BarChart3,
'image': Image,
'shield': ShieldCheck,
'ruler': Ruler,
'box': Box,
}
// Default navigation (used as fallback if API fails)
const defaultNav = [
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0 },
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 },
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 },
]
function buildNavItems(items) {
// Sort by position
const sorted = [...items].sort((a, b) => (a.position || 99) - (b.position || 99))
// Assign section headers based on position ranges
const result = []
let currentSection = null
for (const item of sorted) {
let section = null
if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') {
section = 'Assets'
currentSection = 'Assets'
} else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) {
if (currentSection !== 'Information') {
section = 'Information'
currentSection = 'Information'
}
}
result.push({
...item,
section,
iconComponent: iconMap[item.icon] || null
})
}
return result
}
onMounted(async () => {
getFacilityName().then(name => { facilityName.value = name })
getSiteLogo().then(logo => { siteLogo.value = logo })
getServicenowUrls().then(config => { servicenowConfig.value = config })
try {
const response = await dashboardApi.navigation()
navItems.value = buildNavItems(response.data.data || [])
} catch (err) {
navItems.value = buildNavItems(defaultNav)
}
try {
const resp = await notificationsApi.active()
activeNotifications.value = resp.data.data?.notifications || []
} catch (err) {
// Notifications banner is non-critical
}
})
function getNotificationType(n) {
const type = (n.typename || '').toLowerCase()
if (type.includes('incident')) return 'type-incident'
if (type.includes('change')) return 'type-change'
if (type.includes('awareness')) return 'type-awareness'
return 'type-default'
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function performSearch() {
if (searchQuery.value.trim()) {
router.push({ path: '/search', query: { q: searchQuery.value.trim() } })
searchQuery.value = ''
}
}
async function handleLogout() {
await authStore.logout()
router.push('/login')
}
// Hide a broken avatar (photo filename set but file missing).
function onAvatarError(event) {
event.target.style.display = 'none'
}
</script>
<style scoped>
/* Footer user block: identity row on top, compact icon actions below, all
kept inside the fixed-width sidebar (no full-width buttons overflowing). */
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
.user-identity { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); flex-shrink: 0; }
.user-ident { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
.user-ident .username { margin-bottom: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-sso { font-size: 0.72rem; color: var(--text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-actions { display: flex; gap: 0.5rem; }
.icon-btn {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: rgba(255,255,255,0.8);
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.icon-btn:hover { background: rgba(255,255,255,0.16); color: #fff; }
</style>