403 lines
17 KiB
Vue
403 lines
17 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>
|
|
<!-- Under the site name, where the classic site put it. -->
|
|
<div class="fiscal-week">
|
|
<span class="fiscal-week-label">Fiscal Week</span>
|
|
<span class="fiscal-week-number">{{ currentFiscalWeek }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Drawn only for a caller who can actually search. The server
|
|
answers that, because a guest has no user object here and
|
|
hasPermission would say no for the very visitor the guest grant
|
|
exists to serve. Members who lack assets.view lose a box that only
|
|
ever answered 401 to them. -->
|
|
<div v-if="cansearch" 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 v-if="showDisplays" class="nav-section">Displays</div>
|
|
<a v-if="showShopfloor" :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
|
<a v-if="hasRoute('/tv')" :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
|
<a v-if="hasRoute('/parts-kiosk')" :href="withBase('/parts-kiosk')"
|
|
target="_blank" class="external-link">Parts Kiosk</a>
|
|
<a v-if="hasRoute('/usb-kiosk')" :href="withBase('/usb-kiosk')"
|
|
target="_blank" class="external-link">USB 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 :src="authStore.avatarUrl || fallbackAvatar" class="user-avatar"
|
|
:class="{ 'ge-avatar-fallback': !authStore.avatarUrl }"
|
|
: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, onUnmounted } 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, Wrench, Activity
|
|
} from 'lucide-vue-next'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { currentTheme, toggleTheme } from '../stores/theme'
|
|
import { dashboardApi, notificationsApi } from '../api'
|
|
import { fiscalWeek } from '@/utils/fiscalWeek'
|
|
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
|
import { withBase } from '../utils/basePath'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const authStore = useAuthStore()
|
|
|
|
// The Displays links are hardcoded (not plugin-driven nav), so gate each by
|
|
// whether its route was actually staged into THIS build. A lean site without
|
|
// slides / printedparts must not show a TV Slideshow / Parts Kiosk link that
|
|
// dead-ends on a blank page. Route existence is the true "is it in this build"
|
|
// test - more reliable than plugin-enabled state, which a copied registry can
|
|
// report for a plugin that was never staged.
|
|
const stagedRoutePaths = new Set(router.getRoutes().map(r => r.path))
|
|
const hasRoute = (path) => stagedRoutePaths.has(path)
|
|
// Shopfloor Dashboard is a core view but runs entirely on the notifications
|
|
// plugin (notificationsApi.getShopfloor + the calendar); without it the display
|
|
// is empty, so gate the link on the notifications plugin being in this build.
|
|
const showShopfloor = hasRoute('/shopfloor')
|
|
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
|
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
|
|| hasRoute('/usb-kiosk')
|
|
const routeViewKey = computed(() =>
|
|
route.path.startsWith('/settings') ? '/settings' : route.path
|
|
)
|
|
const searchQuery = ref('')
|
|
// Starts false so the box is never drawn before the server has said the
|
|
// caller can use it. A flash of a control that then vanishes reads worse than
|
|
// one that arrives a moment late.
|
|
const cansearch = ref(false)
|
|
const navItems = ref([])
|
|
const activeNotifications = ref([])
|
|
const facilityName = ref('ShopDB')
|
|
|
|
// A tab left open overnight would otherwise show last week's number until
|
|
// somebody reloaded. Recomputed on a timer rather than only at mount.
|
|
const currentFiscalWeek = ref(fiscalWeek())
|
|
let fiscalWeekTimer = null
|
|
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,
|
|
'wrench': Wrench,
|
|
'activity': Activity,
|
|
}
|
|
|
|
// Default navigation (used as fallback if API fails)
|
|
const defaultNav = [
|
|
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0, permission: 'assets.view' },
|
|
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5, permission: 'notifications.view' },
|
|
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6, permission: 'notifications.view' },
|
|
{ name: 'Map', icon: 'map', route: '/map', position: 4, permission: 'assets.view' },
|
|
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets', permission: 'machines.view' },
|
|
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets', permission: 'computers.view' },
|
|
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets', permission: 'network.view' },
|
|
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets', permission: 'printers.view' },
|
|
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets', permission: 'usb.view' },
|
|
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information', permission: 'applications.view' },
|
|
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information', permission: 'kb.view' },
|
|
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information', permission: 'reports.view' },
|
|
]
|
|
|
|
// The canonical sidebar sections, in the order they render. A nav item DECLARES
|
|
// which one it belongs to; nothing is inferred.
|
|
//
|
|
// Sections used to be derived from the position number - 10-29 became Assets,
|
|
// 30-49 became Information - so a plugin landed in whichever bucket its number
|
|
// happened to fall in. That is how USB Devices, an asset, came to sit under
|
|
// Information next to GE-Enforce and Tech Tools, and why two plugins picking
|
|
// the same number silently reordered each other. A number is for ORDER; it was
|
|
// never a statement about meaning.
|
|
//
|
|
// An item with no section (or an unknown one) stays in the unlabelled group at
|
|
// the top, which is the safe default: it appears, it is just not filed.
|
|
const NAV_SECTIONS = [
|
|
{ key: null, label: null },
|
|
{ key: 'assets', label: 'Assets' },
|
|
{ key: 'information', label: 'Information' },
|
|
]
|
|
|
|
function buildNavItems(items) {
|
|
// Drop items the user cannot reach: a nav item may carry a `permission`
|
|
// (e.g. Slides -> slides.manage); hide it from anyone who lacks it so the
|
|
// link does not dead-end at the router guard. Admins hold every permission.
|
|
//
|
|
// A signed-out visitor sees only what a plugin has declared guest-reachable.
|
|
// Without this the sidebar offers a signed-out reader the whole app and every
|
|
// link bounces them to the login page, which reads as a broken site rather
|
|
// than as a closed door.
|
|
// A nav item may name one permission or several; holding ANY of them is
|
|
// enough. GE-Enforce and Slides need that: the page is reachable by a
|
|
// manager and by a plain viewer, and they hold different strings.
|
|
const holds = item => {
|
|
if (!item.permission) return true
|
|
const names = Array.isArray(item.permission) ? item.permission : [item.permission]
|
|
return names.some(name => authStore.hasPermission(name))
|
|
}
|
|
// A signed-out visitor gets whatever the server sent, which is already
|
|
// filtered to what the guest role can reach - hasPermission knows nothing
|
|
// about guests, so asking it here would hide everything.
|
|
const visible = authStore.isAuthenticated ? items.filter(holds) : items
|
|
|
|
const known = new Set(NAV_SECTIONS.map(s => s.key).filter(Boolean))
|
|
const bucket = item => {
|
|
const declared = (item.section || '').toLowerCase()
|
|
return known.has(declared) ? declared : null
|
|
}
|
|
|
|
const result = []
|
|
for (const { key, label } of NAV_SECTIONS) {
|
|
const inSection = visible
|
|
.filter(item => bucket(item) === key)
|
|
// ?? not ||: position 0 is a real position, and `0 || 99` is 99, which
|
|
// sent Dashboard - the only item that uses 0 - to the end of its group.
|
|
.sort((a, b) => (a.position ?? 99) - (b.position ?? 99))
|
|
inSection.forEach((item, index) => {
|
|
result.push({
|
|
...item,
|
|
// Only the first item in a section carries its heading.
|
|
section: index === 0 ? label : null,
|
|
iconComponent: iconMap[item.icon] || null,
|
|
})
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Refresh the signed-in user (and their permissions) on every app load. The
|
|
// login payload is cached in localStorage, so without this a permission
|
|
// granted mid-session stays invisible until the person logs out and back in.
|
|
if (authStore.isAuthenticated) authStore.fetchUser()
|
|
|
|
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
|
// reload on a screen nobody touches.
|
|
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
|
|
|
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 caps = await dashboardApi.capabilities()
|
|
cansearch.value = !!caps.data.data?.cansearch
|
|
} catch (err) {
|
|
// Leave the box hidden. Guessing from isAuthenticated here would put it
|
|
// back in front of members who cannot search, which is the thing this
|
|
// replaced.
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
const fallbackAvatar = withBase('/ge-monogram.svg')
|
|
|
|
// A broken avatar (photo set but file missing) degrades to the GE monogram.
|
|
function onAvatarError(event) {
|
|
event.target.src = fallbackAvatar
|
|
event.target.classList.add('ge-avatar-fallback')
|
|
}
|
|
onUnmounted(() => {
|
|
if (fiscalWeekTimer) clearInterval(fiscalWeekTimer)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Fiscal week, under the site name, where the classic site put it. The number
|
|
carries the weight; the label is small because it never changes. */
|
|
.fiscal-week {
|
|
margin-top: 0.35rem;
|
|
text-align: center;
|
|
line-height: 1.15;
|
|
}
|
|
.fiscal-week-label {
|
|
display: block;
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
opacity: 0.65;
|
|
}
|
|
.fiscal-week-number {
|
|
display: block;
|
|
font-size: 1.2rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* 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; }
|
|
.ge-avatar-fallback { object-fit: contain; padding: 4px; background: #fff; }
|
|
.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> |