Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bde89c47e | ||
|
|
afd3dce493 | ||
|
|
89248407e7 | ||
|
|
aa6db94179 |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -10,6 +10,55 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.11.2] - 2026-08-17
|
||||||
|
|
||||||
|
Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it.
|
||||||
|
Anyone on 0.11.0 or 0.11.1 with more than one level should take this: until now
|
||||||
|
the map drew markers from every level on whichever floor plan was showing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Markers from other levels were drawn on the displayed level.** The map
|
||||||
|
component filtered out positions with no coordinates and drew everything else,
|
||||||
|
never checking which drawing a position belonged to - the exact failure ADR-017
|
||||||
|
exists to prevent, in the one component that draws the map. Markers are now
|
||||||
|
filtered to the level being shown, and a position with no level is omitted
|
||||||
|
rather than approximated onto the default.
|
||||||
|
- **The map page had no way to choose a level.** It read the current level only
|
||||||
|
to title a PDF export, so a second floor was unreachable from the viewer most
|
||||||
|
people use. A level selector appears when a site has more than one, and
|
||||||
|
switching moves the drawing, the bounds, the coordinate space and the markers
|
||||||
|
together.
|
||||||
|
- **The map editor never accepted a click.** Its click handler was bound only if
|
||||||
|
the map was already in picker mode when it mounted, and the editor opens with
|
||||||
|
nothing selected, so selecting an asset and clicking did nothing at all for the
|
||||||
|
life of the page. The asset forms were unaffected, because their picker is
|
||||||
|
mounted inside a dialog that is already in picker mode.
|
||||||
|
- Existing markers are now drawn while placing one. Positioning an asset relative
|
||||||
|
to the machines already on the floor is the whole task.
|
||||||
|
- Searching the map follows results across levels: a search whose matches are all
|
||||||
|
on another floor showed an empty map while the filter counted them.
|
||||||
|
- **A level's native size could not be set.** The settings page had a width field
|
||||||
|
and no height field at all, and locked both once any marker was placed - which
|
||||||
|
blocked the case the feature was built for, a new blueprint of new dimensions
|
||||||
|
on a floor that already has markers. Both fields are editable, with a
|
||||||
|
confirmation and a pointer to landmark recalibration.
|
||||||
|
- **The same search could return different results.** Fourteen of the sixteen
|
||||||
|
searchers behind global search truncated with `LIMIT` and no `ORDER BY`, so the
|
||||||
|
database was free to return a different subset of matching rows each time.
|
||||||
|
Every searcher now ends in a total order.
|
||||||
|
- Re-searching a term already in the address bar did nothing: the router treats
|
||||||
|
it as a duplicate navigation and aborts, so the results page never re-queried.
|
||||||
|
This is why the sidebar box and the results-page box appeared to disagree.
|
||||||
|
- The empty legend no longer draws its bar and border, which read as a stray
|
||||||
|
input box under the map toolbar.
|
||||||
|
- The map editor's panel header no longer crams a heading and three controls onto
|
||||||
|
one row, which had squeezed the search box until its placeholder read
|
||||||
|
"Search na".
|
||||||
|
- Both map pages had a scrollbar. They subtracted the wrong amount from the
|
||||||
|
viewport height for the page chrome, so each overflowed by the difference; the
|
||||||
|
padding is now a value the pages and the layout share.
|
||||||
|
|
||||||
## [0.11.1] - 2026-08-17
|
## [0.11.1] - 2026-08-17
|
||||||
|
|
||||||
A patch release: the pagination-cap fixes, plus the documentation that missed the
|
A patch release: the pagination-cap fixes, plus the documentation that missed the
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ never by editing this file.
|
|||||||
|
|
||||||
| series | value | governed by |
|
| series | value | governed by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| product `__version__` | `0.11.1` | ADR-007 |
|
| product `__version__` | `0.11.2` | ADR-007 |
|
||||||
| plugin contract `__contract_version__` | `0.20.0` | ADR-002 |
|
| plugin contract `__contract_version__` | `0.20.0` | ADR-002 |
|
||||||
|
|
||||||
They move independently. A contract bump is not a release.
|
They move independently. A contract bump is not a release.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shopdb-frontend",
|
"name": "shopdb-frontend",
|
||||||
"version": "0.11.1",
|
"version": "0.11.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
--sidebar-bg: #00003d;
|
--sidebar-bg: #00003d;
|
||||||
--sidebar-text: #ffffff;
|
--sidebar-text: #ffffff;
|
||||||
--sidebar-width: 250px;
|
--sidebar-width: 250px;
|
||||||
|
/* Vertical chrome around .main-content. A full-height page subtracts these;
|
||||||
|
hardcoding a different number is what put a scrollbar on the map. */
|
||||||
|
--main-pad-top: 20px;
|
||||||
|
--main-pad-bottom: 70px;
|
||||||
|
|
||||||
/* Hover variants */
|
/* Hover variants */
|
||||||
--secondary-dark: #82503f;
|
--secondary-dark: #82503f;
|
||||||
@@ -243,7 +247,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: var(--sidebar-width);
|
margin-left: var(--sidebar-width);
|
||||||
padding: 20px 10px 70px 10px;
|
padding: var(--main-pad-top) 10px var(--main-pad-bottom) 10px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
|
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
|
||||||
<div class="map-legend" v-if="!pickerMode && assetTypeMode">
|
<div class="map-legend" v-if="!pickerMode && assetTypeMode && hasLegendEntries">
|
||||||
<!-- Show subtype legend when a specific type is selected -->
|
<!-- Show subtype legend when a specific type is selected -->
|
||||||
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
|
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
|
||||||
<span
|
<span
|
||||||
@@ -120,6 +120,14 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['markerClick', 'positionPicked'])
|
const emit = defineEmits(['markerClick', 'positionPicked'])
|
||||||
|
|
||||||
|
// An empty legend still drew its bar and border, which looked like a stray input
|
||||||
|
// box under the toolbar. It renders only when it has something in it.
|
||||||
|
const hasLegendEntries = computed(() => {
|
||||||
|
if (props.selectedAssetType && Object.keys(visibleSubtypes.value).length) return true
|
||||||
|
if (Object.keys(visibleAssetTypes.value).length) return true
|
||||||
|
return overlayLegend.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
const mapContainer = ref(null)
|
const mapContainer = ref(null)
|
||||||
let map = null
|
let map = null
|
||||||
let imageOverlay = null
|
let imageOverlay = null
|
||||||
@@ -296,18 +304,23 @@ function initMap() {
|
|||||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
||||||
map.setMaxBounds(bounds)
|
map.setMaxBounds(bounds)
|
||||||
|
|
||||||
// Picker mode: click to set position
|
// Bound UNCONDITIONALLY, and handleMapClick ignores the click when picker mode
|
||||||
if (props.pickerMode) {
|
// is off. Binding it only when pickerMode was true AT MOUNT meant a map that
|
||||||
|
// becomes a picker later never got a click handler at all: the map editor
|
||||||
|
// opens with nothing selected, so clicking to place a marker did nothing, for
|
||||||
|
// the whole life of the page. The asset forms only worked because their picker
|
||||||
|
// is mounted inside a modal that is already in picker mode.
|
||||||
map.on('click', handleMapClick)
|
map.on('click', handleMapClick)
|
||||||
|
|
||||||
// Show initial position if provided
|
if (props.pickerMode && props.initialPosition) {
|
||||||
if (props.initialPosition) {
|
|
||||||
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
// Existing markers are drawn in picker mode too: placing a marker relative to
|
||||||
|
// the ones already on the floor is the whole task. The forms pass no markers,
|
||||||
|
// so this costs them nothing.
|
||||||
renderMarkers()
|
renderMarkers()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleMapClick(e) {
|
function handleMapClick(e) {
|
||||||
if (!props.pickerMode) return
|
if (!props.pickerMode) return
|
||||||
@@ -437,8 +450,18 @@ function renderMarkers() {
|
|||||||
overlayLayers.forEach(layer => layer.remove())
|
overlayLayers.forEach(layer => layer.remove())
|
||||||
overlayLayers = []
|
overlayLayers = []
|
||||||
|
|
||||||
|
// Markers belong to ONE level (ADR-017). This is the drawing being shown, and
|
||||||
|
// anything positioned against a different one is not drawn on it.
|
||||||
|
const drawnLevel = drawnLevelId() ?? null
|
||||||
|
|
||||||
props.machines.forEach(item => {
|
props.machines.forEach(item => {
|
||||||
if (item.mapx == null || item.mapy == null) return
|
if (item.mapx == null || item.mapy == null) return
|
||||||
|
// A marker from another level, placed on THIS blueprint, looks entirely
|
||||||
|
// correct and points at the wrong part of the building - so it is omitted
|
||||||
|
// rather than approximated. A position with no level at all is omitted for
|
||||||
|
// the same reason: the map editor lists both, badged, so they can be fixed
|
||||||
|
// rather than silently misplaced here.
|
||||||
|
if ((item.levelid ?? null) !== drawnLevel) return
|
||||||
|
|
||||||
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
||||||
const leafletY = MAP_HEIGHT - item.mapy
|
const leafletY = MAP_HEIGHT - item.mapy
|
||||||
@@ -599,6 +622,31 @@ watch(() => props.theme, (newTheme) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Switching level changes the drawing, the coordinate space AND which markers
|
||||||
|
// belong on it. All three move together: the size is what marker coordinates
|
||||||
|
// mean, so swapping the image without the bounds would place every marker
|
||||||
|
// against the wrong scale, and keeping the markers would show the previous
|
||||||
|
// floor's assets on this floor's plan.
|
||||||
|
watch(() => drawnLevelId(), (levelid) => {
|
||||||
|
if (!map || !imageOverlay) return
|
||||||
|
|
||||||
|
MAP_WIDTH = dimensionsFor(levelid).width
|
||||||
|
MAP_HEIGHT = dimensionsFor(levelid).height
|
||||||
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
|
|
||||||
|
const url = blueprintUrlFor(props.theme, levelid)
|
||||||
|
// A level with no blueprint is left blank rather than showing the previous
|
||||||
|
// one, which would be a floor plan labelled as somewhere it is not.
|
||||||
|
imageOverlay.setUrl(url || '')
|
||||||
|
imageOverlay.setBounds(bounds)
|
||||||
|
|
||||||
|
map.setMaxBounds(bounds)
|
||||||
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], map.getZoom())
|
||||||
|
|
||||||
|
renderMarkers()
|
||||||
|
loadOverlays()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Load this facility's blueprint + dimensions before building the map so
|
// Load this facility's blueprint + dimensions before building the map so
|
||||||
// bounds and coordinate math use the right size. Falls back to defaults.
|
// bounds and coordinate math use the right size. Falls back to defaults.
|
||||||
|
|||||||
@@ -492,7 +492,7 @@ function cancelEdit() {
|
|||||||
.map-editor {
|
.map-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 40px);
|
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -518,13 +518,30 @@ function cancelEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
|
/* The heading takes its own row and the three controls share the next one(s).
|
||||||
|
All four on a single row inside a 320px panel squeezed the search box until
|
||||||
|
its placeholder read "Search na". */
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header .form-control {
|
||||||
|
/* One control per row. Two of them side by side in a 318px panel left the
|
||||||
|
search box 139px for a placeholder needing 216px, so it read "Search name
|
||||||
|
o" - a control whose own label does not fit is not a narrower control, it is
|
||||||
|
an unlabelled one. */
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header h3 {
|
.panel-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Filter Controls -->
|
<!-- Filter Controls -->
|
||||||
<div class="map-filters">
|
<div class="map-filters">
|
||||||
|
<select
|
||||||
|
v-if="levelChoices.length > 1"
|
||||||
|
v-model.number="shownLevelId"
|
||||||
|
@change="onLevelChange"
|
||||||
|
title="Which floor plan to show"
|
||||||
|
>
|
||||||
|
<option v-for="level in levelChoices" :key="level.levelid" :value="level.levelid">
|
||||||
|
{{ level.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<select v-model="selectedType" @change="onTypeChange">
|
<select v-model="selectedType" @change="onTypeChange">
|
||||||
<option value="">All Asset Types</option>
|
<option value="">All Asset Types</option>
|
||||||
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
|
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
|
||||||
@@ -60,6 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
|
:levelid="shownLevelId"
|
||||||
:machines="filteredAssets"
|
:machines="filteredAssets"
|
||||||
:machinetypes="[]"
|
:machinetypes="[]"
|
||||||
:businessunits="businessunits"
|
:businessunits="businessunits"
|
||||||
@@ -82,7 +94,8 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
|
|||||||
import { assetsApi } from '../api'
|
import { assetsApi } from '../api'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, state as mapConfig } from '@/composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, levelOptions,
|
||||||
|
setCurrentLevel, state as mapConfig } from '@/composables/mapConfig'
|
||||||
import { exportMapPdf } from '../utils/mapPdf'
|
import { exportMapPdf } from '../utils/mapPdf'
|
||||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||||
import { getSubtypeId } from '../utils/mapColors'
|
import { getSubtypeId } from '../utils/mapColors'
|
||||||
@@ -104,6 +117,41 @@ const selectedSubtype = ref('')
|
|||||||
const selectedBusinessUnit = ref('')
|
const selectedBusinessUnit = ref('')
|
||||||
const selectedStatus = ref('')
|
const selectedStatus = ref('')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Which floor plan is on screen. Every marker coordinate is pixels of ONE level
|
||||||
|
// (ADR-017), so this decides both the drawing and which markers belong on it.
|
||||||
|
const shownLevelId = ref(null)
|
||||||
|
|
||||||
|
const levelChoices = computed(() => levelOptions())
|
||||||
|
|
||||||
|
function onLevelChange() {
|
||||||
|
// Keep the shared composable in step, so a map-position picker opened later
|
||||||
|
// starts on the level being looked at rather than the site default.
|
||||||
|
setCurrentLevel(shownLevelId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A search whose only matches are on another floor would otherwise show an empty
|
||||||
|
// map: the assets matched, the filter counted them, and nothing was drawn.
|
||||||
|
// Follow the results to the level that actually holds them - the level with the
|
||||||
|
// most matches, so a search matching several floors lands on the best one.
|
||||||
|
function followSearchAcrossLevels() {
|
||||||
|
if (!searchQuery.value.trim()) return
|
||||||
|
const matches = filteredAssets.value.filter(a => a.mapx != null && a.mapy != null)
|
||||||
|
if (!matches.length) return
|
||||||
|
if (matches.some(a => (a.levelid ?? null) === shownLevelId.value)) return
|
||||||
|
|
||||||
|
const tally = new Map()
|
||||||
|
matches.forEach(asset => {
|
||||||
|
const levelid = asset.levelid ?? null
|
||||||
|
if (levelid === null) return
|
||||||
|
tally.set(levelid, (tally.get(levelid) || 0) + 1)
|
||||||
|
})
|
||||||
|
if (!tally.size) return
|
||||||
|
|
||||||
|
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||||
|
shownLevelId.value = best
|
||||||
|
setCurrentLevel(best)
|
||||||
|
}
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
|
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
@@ -245,12 +293,12 @@ async function exportPdf() {
|
|||||||
// blueprintUrlFor applies withBase - the raw setting value is a
|
// blueprintUrlFor applies withBase - the raw setting value is a
|
||||||
// root-relative /api path, which 404s under a subpath mount like /ops.
|
// root-relative /api path, which 404s under a subpath mount like /ops.
|
||||||
// The PDF always uses the light blueprint: it prints on white paper.
|
// The PDF always uses the light blueprint: it prints on white paper.
|
||||||
blueprintUrl: blueprintUrlFor('light', mapConfig.currentlevelid),
|
blueprintUrl: blueprintUrlFor('light', shownLevelId.value),
|
||||||
mapWidth: dimensionsFor(mapConfig.currentlevelid).width,
|
mapWidth: dimensionsFor(shownLevelId.value).width,
|
||||||
mapHeight: dimensionsFor(mapConfig.currentlevelid).height,
|
mapHeight: dimensionsFor(shownLevelId.value).height,
|
||||||
// Named on the sheet, because a floor plan with no level on it is not
|
// Named on the sheet, because a floor plan with no level on it is not
|
||||||
// identifiable once it is printed and carried to the floor.
|
// identifiable once it is printed and carried to the floor.
|
||||||
levelname: levelName(mapConfig.currentlevelid),
|
levelname: levelName(shownLevelId.value),
|
||||||
selectedType: selectedType.value,
|
selectedType: selectedType.value,
|
||||||
subtypeColors: subtypeColorMap.value,
|
subtypeColors: subtypeColorMap.value,
|
||||||
subtypeNames: subtypeNameMap.value,
|
subtypeNames: subtypeNameMap.value,
|
||||||
@@ -265,7 +313,8 @@ async function exportPdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadMapConfig()
|
await loadMapConfig()
|
||||||
|
shownLevelId.value = mapConfig.currentlevelid ?? mapConfig.defaultlevelid ?? null
|
||||||
try {
|
try {
|
||||||
const response = await assetsApi.getMap()
|
const response = await assetsApi.getMap()
|
||||||
const data = response.data.data || {}
|
const data = response.data.data || {}
|
||||||
@@ -305,6 +354,7 @@ function updateMapLayers() {
|
|||||||
function debouncedSearch() {
|
function debouncedSearch() {
|
||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout)
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
|
followSearchAcrossLevels()
|
||||||
updateMapLayers()
|
updateMapLayers()
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
@@ -318,7 +368,7 @@ function handleMarkerClick(asset) {
|
|||||||
.map-page {
|
.map-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 2rem);
|
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page .page-header {
|
.map-page .page-header {
|
||||||
|
|||||||
@@ -213,9 +213,18 @@ async function search(q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function performSearch() {
|
function performSearch() {
|
||||||
if (searchInput.value.trim()) {
|
const term = searchInput.value.trim()
|
||||||
router.push({ path: '/search', query: { q: 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) {
|
async function openKBArticle(result) {
|
||||||
|
|||||||
@@ -62,18 +62,36 @@
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mono">{{ level.mapwidth }} x {{ level.mapheight }}</span>
|
<div class="size-fields">
|
||||||
<small v-if="level.assetcount" class="input-hint">
|
|
||||||
fixed while {{ level.assetcount }} marker(s) are placed
|
|
||||||
</small>
|
|
||||||
<input
|
<input
|
||||||
v-else
|
|
||||||
v-model.number="level.mapwidth"
|
v-model.number="level.mapwidth"
|
||||||
type="number"
|
type="number"
|
||||||
|
min="1"
|
||||||
class="form-control size-input"
|
class="form-control size-input"
|
||||||
@blur="saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })"
|
aria-label="Blueprint width in pixels"
|
||||||
|
@blur="saveSize(level)"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
/>
|
/>
|
||||||
|
<span class="size-x">x</span>
|
||||||
|
<input
|
||||||
|
v-model.number="level.mapheight"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-control size-input"
|
||||||
|
aria-label="Blueprint height in pixels"
|
||||||
|
@blur="saveSize(level)"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small v-if="level.assetcount" class="input-hint">
|
||||||
|
Taken from the image while a level is empty. Changing it now
|
||||||
|
re-scales where all {{ level.assetcount }} marker(s) sit relative
|
||||||
|
to the drawing - set the new size, then Recalibrate from
|
||||||
|
landmarks in the map editor.
|
||||||
|
</small>
|
||||||
|
<small v-else class="input-hint">
|
||||||
|
Set from the blueprint you upload.
|
||||||
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
|
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
|
||||||
@@ -311,6 +329,29 @@ async function saveLevel(level, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveSize(level) {
|
||||||
|
if (!(level.mapwidth > 0) || !(level.mapheight > 0)) {
|
||||||
|
error.value = 'Width and height must both be positive.'
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A level with markers keeps its coordinates when the size changes, so every
|
||||||
|
// marker moves relative to the drawing. That is sometimes exactly right (a
|
||||||
|
// re-export of the same plan at a new resolution) and sometimes the start of
|
||||||
|
// a recalibration, but it is never something to do by accident.
|
||||||
|
if (level.assetcount) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`${level.levelname} has ${level.assetcount} marker(s) placed against ` +
|
||||||
|
`${level.mapwidth} x ${level.mapheight}. Changing the size moves all of ` +
|
||||||
|
`them relative to the drawing. Recalibrate from landmarks afterwards. Continue?`)
|
||||||
|
if (!ok) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })
|
||||||
|
}
|
||||||
|
|
||||||
async function upload(level, theme, event) {
|
async function upload(level, theme, event) {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -398,6 +439,8 @@ async function remove(level) {
|
|||||||
.mono { font-family: monospace; }
|
.mono { font-family: monospace; }
|
||||||
.order-input { width: 4.5rem; }
|
.order-input { width: 4.5rem; }
|
||||||
.size-input { width: 6rem; }
|
.size-input { width: 6rem; }
|
||||||
|
.size-fields { display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.size-x { color: var(--text-light); }
|
||||||
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
|
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
.map-thumb {
|
.map-thumb {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ __contract_version__ = '0.20.0'
|
|||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
# bump rules. Not part of the shopdb.api contract surface, so it is
|
# bump rules. Not part of the shopdb.api contract surface, so it is
|
||||||
# not re-exported there.
|
# not re-exported there.
|
||||||
__version__ = '0.11.1'
|
__version__ = '0.11.2'
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name: str = None) -> Flask:
|
def create_app(config_name: str = None) -> Flask:
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
|
|||||||
search_bp = Blueprint('search', __name__)
|
search_bp = Blueprint('search', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# EVERY searcher below truncates with .limit(). An unordered LIMIT lets the
|
||||||
|
# database return a DIFFERENT subset of the matching rows between two identical
|
||||||
|
# requests - the same search run twice came back with different hits, which reads
|
||||||
|
# as the search being broken rather than as a missing ORDER BY. Each query
|
||||||
|
# therefore ends with a TOTAL order: a display key plus the primary key, so ties
|
||||||
|
# cannot reorder. Relevance is applied afterwards in Python, over a stable set.
|
||||||
|
|
||||||
|
|
||||||
def _word_match(query, *columns):
|
def _word_match(query, *columns):
|
||||||
"""SQL clause matching rows that contain EVERY word of the query, each word
|
"""SQL clause matching rows that contain EVERY word of the query, each word
|
||||||
in any of the columns, in any order.
|
in any of the columns, in any order.
|
||||||
@@ -213,7 +221,7 @@ def _search_applications(query, search_term):
|
|||||||
apps = Application.query.filter(
|
apps = Application.query.filter(
|
||||||
Application.isactive == True,
|
Application.isactive == True,
|
||||||
_word_match(query, Application.appname, Application.appdescription)
|
_word_match(query, Application.appname, Application.appdescription)
|
||||||
).limit(10).all()
|
).order_by(Application.appname, Application.appid).limit(10).all()
|
||||||
|
|
||||||
for app in apps:
|
for app in apps:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
@@ -258,7 +266,8 @@ def _search_knowledgebase(query, search_term):
|
|||||||
KnowledgeBase.appid.notin_(retired)),
|
KnowledgeBase.appid.notin_(retired)),
|
||||||
_word_match(query, KnowledgeBase.shortdescription,
|
_word_match(query, KnowledgeBase.shortdescription,
|
||||||
KnowledgeBase.keywords)
|
KnowledgeBase.keywords)
|
||||||
).limit(20).all()
|
).order_by(KnowledgeBase.clicks.desc(),
|
||||||
|
KnowledgeBase.linkid).limit(20).all()
|
||||||
|
|
||||||
for kb in kb_articles:
|
for kb in kb_articles:
|
||||||
relevance = 10 + (kb.clicks or 0) * 0.1
|
relevance = 10 + (kb.clicks or 0) * 0.1
|
||||||
@@ -378,7 +387,7 @@ def _search_assets(query, search_term):
|
|||||||
_word_match(query, Asset.assetnumber, Asset.name,
|
_word_match(query, Asset.assetnumber, Asset.name,
|
||||||
Asset.serialnumber, Asset.notes,
|
Asset.serialnumber, Asset.notes,
|
||||||
Asset.gaugelabreference, Asset.maintenancereference)
|
Asset.gaugelabreference, Asset.maintenancereference)
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
relevance = 15
|
relevance = 15
|
||||||
@@ -421,7 +430,7 @@ def _search_measuringtools(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Asset.assetnumber, Asset.name,
|
_word_match(query, Asset.assetnumber, Asset.name,
|
||||||
Asset.serialnumber, Asset.gaugelabreference)
|
Asset.serialnumber, Asset.gaugelabreference)
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
relevance = 15
|
relevance = 15
|
||||||
@@ -461,7 +470,8 @@ def _search_usbdevices(query, search_term):
|
|||||||
USBDevice.isactive == True,
|
USBDevice.isactive == True,
|
||||||
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
||||||
USBDevice.label, USBDevice.productname)
|
USBDevice.label, USBDevice.productname)
|
||||||
).limit(10).all()
|
).order_by(USBDevice.serialnumber,
|
||||||
|
USBDevice.usbdeviceid).limit(10).all()
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
@@ -507,7 +517,8 @@ def _search_printeditems(query, search_term):
|
|||||||
PrintedItem.isactive == True,
|
PrintedItem.isactive == True,
|
||||||
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
|
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
|
||||||
PrintedItem.itemname, PrintedItem.itemdescription)
|
PrintedItem.itemname, PrintedItem.itemdescription)
|
||||||
).limit(10).all()
|
).order_by(PrintedItem.itemcode,
|
||||||
|
PrintedItem.printeditemid).limit(10).all()
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
@@ -563,7 +574,7 @@ def _search_customfields(query, search_term):
|
|||||||
CustomField.searchable == True,
|
CustomField.searchable == True,
|
||||||
CustomField.isactive == True,
|
CustomField.isactive == True,
|
||||||
_word_match(query, CustomFieldValue.value),
|
_word_match(query, CustomFieldValue.value),
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, CustomField.fieldid).limit(15).all()
|
||||||
|
|
||||||
for asset, field in rows:
|
for asset, field in rows:
|
||||||
result = _get_asset_result(asset, query, relevance=40)
|
result = _get_asset_result(asset, query, relevance=40)
|
||||||
@@ -583,7 +594,8 @@ def _search_by_ip(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Communication.asset).joinedload(Asset.assettype),
|
joinedload(Communication.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Communication.asset).joinedload(Asset.location),
|
joinedload(Communication.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Communication.ipaddress,
|
||||||
|
Communication.communicationid).limit(10).all()
|
||||||
|
|
||||||
seen_assets = set()
|
seen_assets = set()
|
||||||
for comm in comms:
|
for comm in comms:
|
||||||
@@ -643,7 +655,7 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Computer.asset).joinedload(Asset.assettype),
|
joinedload(Computer.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Computer.asset).joinedload(Asset.location),
|
joinedload(Computer.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Computer.hostname, Computer.computerid).limit(10).all()
|
||||||
|
|
||||||
for comp in computers:
|
for comp in computers:
|
||||||
if comp.asset and comp.asset.isactive:
|
if comp.asset and comp.asset.isactive:
|
||||||
@@ -666,7 +678,7 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Printer.asset).joinedload(Asset.assettype),
|
joinedload(Printer.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Printer.asset).joinedload(Asset.location),
|
joinedload(Printer.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Printer.hostname, Printer.printerid).limit(10).all()
|
||||||
|
|
||||||
for printer in printers:
|
for printer in printers:
|
||||||
if printer.asset and printer.asset.isactive:
|
if printer.asset and printer.asset.isactive:
|
||||||
@@ -689,7 +701,8 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
||||||
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(NetworkDevice.hostname,
|
||||||
|
NetworkDevice.networkdeviceid).limit(10).all()
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
if device.asset and device.asset.isactive:
|
if device.asset and device.asset.isactive:
|
||||||
@@ -773,7 +786,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor, Model.modelnumber,
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
||||||
MachineType.machinetype)
|
MachineType.machinetype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in machine_assets:
|
for asset in machine_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
@@ -801,7 +814,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor, Model.modelnumber,
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
||||||
PrinterType.printertype)
|
PrinterType.printertype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in printer_assets:
|
for asset in printer_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
@@ -827,7 +840,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor,
|
_word_match(query, Vendor.vendor,
|
||||||
NetworkDeviceType.networkdevicetype)
|
NetworkDeviceType.networkdevicetype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in netdev_assets:
|
for asset in netdev_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
|
|||||||
Reference in New Issue
Block a user