Five defects, four of them mine from 0.11.0, found by using the feature. THE SERIOUS ONE: ShopFloorMap never checked a marker's level. It skipped null coordinates and drew everything else on whatever blueprint was displayed, so level 1 markers appeared on level 2 - the exact failure ADR-017 exists to prevent, in the one component that draws the map. The build gate did not catch it because that rule checks files EMITTING mapx, not the component consuming it. Markers are now filtered to the drawn level, and a position with no level is omitted rather than approximated. The map page had no way to choose a level at all. It read currentlevelid only to title the PDF, so a second floor was unreachable from the viewer that most people use. Adds a level selector (hidden when a site has one level), passes it to the map, and switches the drawing, the bounds, the coordinate space and the markers together - swapping the image without the bounds would place every marker against the wrong scale. Searching the map now follows results across levels: a search whose matches are all on another floor showed an empty map while the filter counted them. Floor map settings had NO height input - only width - so a level's native size could not be set even while empty, which is the one time it is editable. Both fields are there now, and size is editable on a level that has markers, because refusing it blocked the case the feature was built for: a new blueprint of new dimensions on a floor already full of markers. It confirms first and points at landmark recalibration. Search results differed between the sidebar box and the results-page box: - 14 of 16 searchers truncated with .limit() and no ORDER BY, so the database could return a DIFFERENT subset of matching rows for the same query. Every searcher now ends in a total order (display key plus primary key). - Searching a term already in the URL was a duplicate navigation the router aborts, so the route watcher never fired and the button did nothing. The sidebar never hit this, because it always navigates from another page - which is why the two boxes appeared to disagree. Also removes a scrollbar from both map pages. They subtracted 2rem and 40px from 100vh for the page chrome, which is really 90px of padding on .main-content, so each overflowed by the difference. The padding is now a CSS variable both the layout and the pages read. Measured in the browser before and after: 1058 vs a 1000px viewport, now 1000.
495 lines
14 KiB
Vue
495 lines
14 KiB
Vue
<template>
|
|
<div class="search-results">
|
|
<div class="page-header">
|
|
<h2>Search Results</h2>
|
|
<span v-if="results.length" class="results-count">
|
|
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
|
|
</span>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input
|
|
v-model="searchInput"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
|
|
@keyup.enter="performSearch"
|
|
/>
|
|
<button class="btn btn-primary" @click="performSearch">Search</button>
|
|
</div>
|
|
|
|
<div v-if="results.length" class="filter-buttons">
|
|
<button
|
|
v-for="filter in filterList"
|
|
:key="filter.key"
|
|
class="filter-btn"
|
|
:class="{ active: activeFilter === filter.key }"
|
|
@click="activeFilter = filter.key"
|
|
>
|
|
{{ filter.label }}
|
|
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Searching...</div>
|
|
|
|
<template v-else-if="query">
|
|
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
|
|
No {{ activeFilter }} results for "{{ query }}"
|
|
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
|
|
</div>
|
|
|
|
<div v-else-if="results.length === 0" class="no-results">
|
|
No results found for "{{ query }}"
|
|
</div>
|
|
|
|
<div v-else class="results-list">
|
|
<div
|
|
v-for="result in filteredResults"
|
|
:key="`${result.type}-${result.id}`"
|
|
:id="`result-${result.type}-${result.id}`"
|
|
class="result-item"
|
|
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
|
|
>
|
|
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
|
|
<div class="result-content">
|
|
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
|
|
{{ result.title }}
|
|
</router-link>
|
|
<a
|
|
v-else
|
|
href="#"
|
|
class="result-title"
|
|
@click.prevent="openKBArticle(result)"
|
|
>
|
|
{{ result.title }}
|
|
</a>
|
|
<div class="result-meta">
|
|
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
|
|
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
|
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
|
|
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="share-btn"
|
|
@click="shareResult(result)"
|
|
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
|
|
>
|
|
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="no-results">
|
|
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { searchApi, knowledgebaseApi } from '../api'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const loading = ref(false)
|
|
const results = ref([])
|
|
const query = ref('')
|
|
const searchInput = ref('')
|
|
const activeFilter = ref('all')
|
|
const typeCounts = ref({})
|
|
const totalAll = ref(0)
|
|
const highlightId = ref(null)
|
|
const copiedId = ref(null)
|
|
|
|
const typeLabels = {
|
|
machine: 'Machine',
|
|
pc: 'PC',
|
|
computer: 'PC',
|
|
application: 'App',
|
|
knowledgebase: 'KB',
|
|
printer: 'Printer',
|
|
network_device: 'Network',
|
|
measuring_tool: 'Measuring Tool',
|
|
employee: 'Employee',
|
|
notification: 'Notice',
|
|
subnet: 'Subnet',
|
|
usb_device: 'USB',
|
|
printed_item: 'Printed Part'
|
|
}
|
|
|
|
const filterTypeMap = {
|
|
all: null,
|
|
machines: ['machine'],
|
|
computers: ['computer'],
|
|
printers: ['printer'],
|
|
network: ['network_device', 'subnet'],
|
|
measuringtools: ['measuring_tool'],
|
|
applications: ['application'],
|
|
knowledgebase: ['knowledgebase'],
|
|
notifications: ['notification'],
|
|
employees: ['employee'],
|
|
usb: ['usb_device'],
|
|
printedparts: ['printed_item']
|
|
}
|
|
|
|
const filterList = [
|
|
{ key: 'all', label: 'All' },
|
|
{ key: 'machines', label: 'Machines' },
|
|
{ key: 'computers', label: 'PCs' },
|
|
{ key: 'printers', label: 'Printers' },
|
|
{ key: 'network', label: 'Network' },
|
|
{ key: 'measuringtools', label: 'Measuring Tools' },
|
|
{ key: 'applications', label: 'Apps' },
|
|
{ key: 'knowledgebase', label: 'KB' },
|
|
{ key: 'notifications', label: 'Notices' },
|
|
{ key: 'employees', label: 'Employees' },
|
|
{ key: 'usb', label: 'USB' },
|
|
{ key: 'printedparts', label: 'Printed Parts' }
|
|
]
|
|
|
|
function typeLabel(type) {
|
|
return typeLabels[type] || type
|
|
}
|
|
|
|
function getFilterCount(filterKey) {
|
|
if (filterKey === 'all') return totalAll.value || results.value.length
|
|
const types = filterTypeMap[filterKey]
|
|
if (!types) return 0
|
|
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
|
|
}
|
|
|
|
const filteredResults = computed(() => {
|
|
if (activeFilter.value === 'all') return results.value
|
|
const types = filterTypeMap[activeFilter.value]
|
|
if (!types) return results.value
|
|
return results.value.filter(r => types.includes(r.type))
|
|
})
|
|
|
|
async function search(q) {
|
|
if (!q || q.length < 2) {
|
|
results.value = []
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
activeFilter.value = 'all'
|
|
|
|
try {
|
|
const response = await searchApi.search(q)
|
|
const data = response.data.data
|
|
|
|
// Handle ServiceNOW redirect
|
|
if (data?.redirect?.type === 'servicenow') {
|
|
window.open(data.redirect.url, '_blank')
|
|
query.value = q
|
|
results.value = []
|
|
loading.value = false
|
|
return
|
|
}
|
|
|
|
// Smart redirect - auto-navigate to exact match
|
|
if (data?.redirect) {
|
|
router.replace(data.redirect.url)
|
|
return
|
|
}
|
|
|
|
results.value = data?.results || []
|
|
typeCounts.value = data?.counts || {}
|
|
totalAll.value = data?.total_all || results.value.length
|
|
query.value = q
|
|
} catch (error) {
|
|
console.error('Search error:', error)
|
|
results.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function performSearch() {
|
|
const term = searchInput.value.trim()
|
|
if (!term) return
|
|
// Searching a term that is ALREADY in the URL is a duplicate navigation: the
|
|
// router aborts it, so the route watcher never fires and the button does
|
|
// nothing at all. Re-run the query directly in that case. The sidebar box
|
|
// never hits this, because it is always navigating from somewhere else -
|
|
// which is why the two search boxes appeared to behave differently.
|
|
if (term === route.query.q) {
|
|
search(term)
|
|
return
|
|
}
|
|
router.push({ path: '/search', query: { q: term } })
|
|
}
|
|
|
|
async function openKBArticle(result) {
|
|
try {
|
|
await knowledgebaseApi.trackClick(result.id)
|
|
if (result.linkurl) {
|
|
window.open(result.linkurl, '_blank')
|
|
} else {
|
|
router.push(result.url)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error tracking click:', error)
|
|
if (result.linkurl) {
|
|
window.open(result.linkurl, '_blank')
|
|
} else {
|
|
router.push(result.url)
|
|
}
|
|
}
|
|
}
|
|
|
|
function shareResult(result) {
|
|
const url = new URL(window.location.href)
|
|
url.searchParams.set('highlight', `${result.type}-${result.id}`)
|
|
navigator.clipboard.writeText(url.toString()).then(() => {
|
|
copiedId.value = `${result.type}-${result.id}`
|
|
setTimeout(() => { copiedId.value = null }, 2000)
|
|
})
|
|
}
|
|
|
|
function scrollToHighlight() {
|
|
if (highlightId.value) {
|
|
nextTick(() => {
|
|
const el = document.getElementById(`result-${highlightId.value}`)
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
setTimeout(() => { highlightId.value = null }, 3000)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
const q = route.query.q
|
|
const hl = route.query.highlight
|
|
if (hl) highlightId.value = hl
|
|
if (q) {
|
|
searchInput.value = q
|
|
search(q)
|
|
}
|
|
})
|
|
|
|
watch(() => route.query.q, (newQ) => {
|
|
if (newQ) {
|
|
searchInput.value = newQ
|
|
const hl = route.query.highlight
|
|
if (hl) highlightId.value = hl
|
|
search(newQ)
|
|
}
|
|
})
|
|
|
|
watch(results, () => {
|
|
scrollToHighlight()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.page-header h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.results-count {
|
|
color: var(--text-light);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.search-box {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.search-box input {
|
|
flex: 1;
|
|
}
|
|
|
|
.filter-buttons {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.375rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.filter-btn {
|
|
padding: 0.3rem 0.6rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.filter-btn.active {
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.filter-btn:hover:not(.active) {
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.filter-count {
|
|
background: rgba(128, 128, 128, 0.15);
|
|
padding: 0.1rem 0.35rem;
|
|
border-radius: 8px;
|
|
font-size: 0.75rem;
|
|
min-width: 1.25rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.filter-btn.active .filter-count {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
.no-results {
|
|
text-align: center;
|
|
color: var(--text-light);
|
|
padding: 2rem;
|
|
}
|
|
|
|
.results-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.result-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background 0.3s ease;
|
|
}
|
|
|
|
.result-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.result-item.highlighted {
|
|
background: rgba(65, 129, 255, 0.08);
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.result-type {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
padding: 0.2rem 0.45rem;
|
|
border-radius: 4px;
|
|
min-width: 65px;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Per-domain badge palette. Values live in CSS variables on the container so
|
|
the dark theme overrides them in one place (below) instead of restating
|
|
every selector. Each badge rule just references its pair. */
|
|
.search-results {
|
|
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
|
|
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
|
|
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
|
|
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
|
|
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
|
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
|
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
|
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
|
|
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
|
|
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
|
|
}
|
|
|
|
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
|
|
.result-type.pc,
|
|
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
|
|
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
|
|
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
|
|
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
|
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
|
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
|
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
|
|
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
|
|
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
|
|
|
|
.result-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.result-title {
|
|
color: var(--link);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.result-title:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.result-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
margin-top: 0.25rem;
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.result-subtitle {
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.result-ticket {
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.share-btn {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
color: var(--text-light);
|
|
font-size: 0.7rem;
|
|
padding: 0.2rem 0.4rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.share-btn:hover {
|
|
color: var(--primary);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.search-results {
|
|
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
|
|
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
|
|
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
|
|
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
|
|
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
|
|
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
|
|
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
|
|
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
|
|
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
|
|
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
|
|
}
|
|
}
|
|
</style>
|