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.
170 lines
4.6 KiB
Vue
170 lines
4.6 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>{{ isEdit ? 'Edit Article' : 'Add Knowledge Base Article' }}</h2>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<form v-else @submit.prevent="saveArticle">
|
|
<div class="form-group">
|
|
<label for="shortdescription">Description *</label>
|
|
<input
|
|
id="shortdescription"
|
|
v-model="form.shortdescription"
|
|
type="text"
|
|
class="form-control"
|
|
required
|
|
maxlength="500"
|
|
placeholder="Brief description of the article"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="linkurl">URL *</label>
|
|
<input
|
|
id="linkurl"
|
|
v-model="form.linkurl"
|
|
type="url"
|
|
class="form-control"
|
|
required
|
|
maxlength="2000"
|
|
placeholder="https://..."
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="keywords">Keywords</label>
|
|
<input
|
|
id="keywords"
|
|
v-model="form.keywords"
|
|
type="text"
|
|
class="form-control"
|
|
maxlength="500"
|
|
placeholder="Space-separated keywords"
|
|
/>
|
|
<small class="form-hint">Keywords help with search - separate with spaces</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="appid">Topic (Application)</label>
|
|
<select
|
|
id="appid"
|
|
v-model="form.appid"
|
|
class="form-control"
|
|
>
|
|
<option value="">-- Select Topic (Optional) --</option>
|
|
<option
|
|
v-for="app in applications"
|
|
:key="app.appid"
|
|
:value="app.appid"
|
|
>
|
|
{{ app.appname }}
|
|
</option>
|
|
</select>
|
|
<small class="form-hint">Select the application/topic this article relates to</small>
|
|
</div>
|
|
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
{{ saving ? 'Saving...' : (isEdit ? 'Update Article' : 'Add Article') }}
|
|
</button>
|
|
<router-link to="/knowledgebase" class="btn btn-secondary">Cancel</router-link>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { knowledgebaseApi, applicationsApi } from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const isEdit = computed(() => !!route.params.id)
|
|
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
|
|
const form = ref({
|
|
shortdescription: '',
|
|
linkurl: '',
|
|
keywords: '',
|
|
appid: ''
|
|
})
|
|
|
|
const applications = ref([])
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
// Load applications for topic dropdown
|
|
const appsRes = 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.
|
|
applications.value = appsRes.data.data || []
|
|
|
|
// Load article if editing
|
|
if (isEdit.value) {
|
|
const response = await knowledgebaseApi.get(route.params.id)
|
|
const article = response.data.data
|
|
|
|
form.value = {
|
|
shortdescription: article.shortdescription || '',
|
|
linkurl: article.linkurl || '',
|
|
keywords: article.keywords || '',
|
|
appid: article.application?.appid || ''
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading data:', err)
|
|
error.value = 'Failed to load data'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
async function saveArticle() {
|
|
error.value = ''
|
|
saving.value = true
|
|
|
|
try {
|
|
const articleData = {
|
|
shortdescription: form.value.shortdescription,
|
|
linkurl: form.value.linkurl,
|
|
keywords: form.value.keywords || null,
|
|
appid: form.value.appid || null
|
|
}
|
|
|
|
if (isEdit.value) {
|
|
await knowledgebaseApi.update(route.params.id, articleData)
|
|
} else {
|
|
await knowledgebaseApi.create(articleData)
|
|
}
|
|
|
|
router.push('/knowledgebase')
|
|
} catch (err) {
|
|
console.error('Error saving article:', err)
|
|
error.value = apiError(err, 'Failed to save article')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.form-hint {
|
|
display: block;
|
|
margin-top: 0.25rem;
|
|
font-size: 0.8rem;
|
|
color: var(--text-light, #666);
|
|
}
|
|
</style>
|