Files
shopdb-flask/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
cproudlock 3324dbd91e
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
Buildings and levels for the floor map, and make every identifier searchable
The map was one picture of one floor. A second floor was added, the blueprint
changed size, and machines moved, so a position now records WHICH DRAWING its
coordinates belong to.

Buildings and levels (ADR-017). Each level owns its blueprint per theme and its
own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the
site. A position whose level is unknown renders "level unknown" and is never
drawn on the default level, because a marker on the wrong floor plan looks
entirely correct while pointing at the wrong place.

Repositioning in bulk: filter by unplaced, needs-review or level, search, place,
confirm. Landmark recalibration solves the transform PER AXIS from landmark
pairs and never from image dimensions - the canvas grew taller without
rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong
everywhere. It defaults to a dry run, reports what would land off the drawing,
snapshots before applying, and clears mapverifiedat because a transform is a
guess awaiting review. Snapshots restore, including the level and the review
state, and a restore snapshots first so an undo is undoable.

Search: gaugelabreference was matched only for measuring tools and
maintenancereference was matched nowhere at all, for any asset type, while
Settings happily offers both identifiers on machines and PCs. A tag an operator
is told to record has to be findable or it is a write-only field. USB devices
and printed items were unreachable from search entirely - neither is an asset,
so the generic asset search could not see them and no searcher existed; they
now match on serial, asset tag, label, bin code and gage-lab tag, honouring
isactive, with Settings toggles and result labels to match.

The retired-application rule was half a rule: GET /api/knowledgebase hid
articles whose topic application is retired while global search still returned
them and printed the retired application as the subject. A filter is only real
if every path that reaches the row applies it.

Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location
gained levelid, and resolve_asset_position returns the levelid belonging to
whichever source supplied the coordinates. The five plugins that write a map
position are re-pinned. The install-list text format gained levelid as a NINTH
field, appended, because the shipped Pascal installer reads fields 0-7 by index.

That installer still compiles in one drawing's dimensions and bundles one
blueprint, so its map is accurate for the default level only; /api/maplevels is
deliberately unauthenticated so it can read both at runtime once rebuilt.
Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps.

Migration 7d33 converts an existing single-map site into one building and one
default level carrying the old map_* settings, then assigns every placed asset
and location to it. Nothing moves on screen. Old settings rows are kept so a
rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
2026-08-17 12:55:51 -04:00

279 lines
7.7 KiB
Vue

<template>
<div>
<div class="page-header">
<div class="header-left">
<h2>Knowledge Base</h2>
<span v-if="stats" class="stats-badge">
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
</span>
</div>
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search articles..."
@input="debouncedSearch"
/>
<select v-model="topicFilter" class="form-control" @change="applyFilter">
<option value="">All Topics</option>
<option
v-for="app in topics"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable" @click="toggleSort('topic')">
Topic
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" @click="toggleSort('description')">
Description
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
Clicks
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="article in articles" :key="article.linkid">
<td>
<router-link
v-if="article.application"
:to="`/applications/${article.application.appid}`"
>
{{ article.application.appname }}
</router-link>
<span v-else class="text-muted">-</span>
</td>
<td>
<a
href="#"
class="article-link"
@click.prevent="openArticle(article)"
:title="article.linkurl"
>
{{ truncate(article.shortdescription, 95) }}
</a>
</td>
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
<td class="actions">
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
View
</router-link>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
Edit
</router-link>
</td>
</tr>
<tr v-if="articles.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No articles found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
const perPage = ref(20)
const totalPages = ref(1)
const topicFilter = ref('')
const sort = ref('clicks')
const order = ref('desc')
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadArticles(),
loadTopics(),
loadStats()
])
})
// A filter change must go back to page 1. Selecting a filter while on page 5
// asked the server for page 5 of a result set that now has one page, and the
// list came back empty as though the filter matched nothing.
//
// setPage(1) writes the URL, which the composable's watcher picks up and
// answers with onChange - so calling the loader as well would fetch twice.
// Load directly only when already on page 1, where nothing changes and the
// watcher stays silent.
function applyFilter() {
if (page.value > 1) setPage(1)
else loadArticles()
}
async function loadArticles() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value,
sort: sort.value,
order: order.value
}
if (search.value) params.search = search.value
if (topicFilter.value) params.appid = topicFilter.value
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)
} finally {
loading.value = false
}
}
async function loadTopics() {
try {
const response = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic:
// ishidden governs whether an application shows on the tiles page, which
// says nothing about whether it can be the subject of an article.
topics.value = response.data.data || []
} catch (error) {
console.error('Error loading topics:', error)
}
}
async function loadStats() {
try {
const response = await knowledgebaseApi.getStats()
stats.value = response.data.data
} catch (error) {
console.error('Error loading stats:', error)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadArticles()
}, 300)
}
function goToPage(p) {
setPage(p)
loadArticles()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadArticles()
}
function toggleSort(column) {
if (sort.value === column) {
order.value = order.value === 'desc' ? 'asc' : 'desc'
} else {
sort.value = column
order.value = 'desc'
}
loadArticles()
}
function truncate(text, length) {
if (!text) return ''
if (text.length <= length) return text
return text.substring(0, length) + '...'
}
async function openArticle(article) {
try {
await knowledgebaseApi.trackClick(article.linkid)
article.clicks = (article.clicks || 0) + 1
if (stats.value) {
stats.value.totalclicks++
}
if (article.linkurl) {
window.open(article.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
if (article.linkurl) {
window.open(article.linkurl, '_blank')
}
}
}
</script>
<style scoped>
/* Knowledge Base specific styles only */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-left h2 {
margin: 0;
}
.stats-badge {
background: var(--bg);
color: var(--link);
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
}
.article-link {
color: var(--text);
}
.article-link:hover {
color: var(--link);
}
.text-muted {
color: var(--text-light);
}
</style>