Files
shopdb-flask/plugins/guides/frontend/views/GuideView.vue
cproudlock 38d1e367fc Guides: write a procedure in ShopDB, not beside it
The knowledgebase plugin is a link registry - a description, a URL and some
keywords pointing at a document held somewhere else. That leaves the documents
themselves as HTML files on a share: no search, no history, no way to know
whether the copy pinned to a noticeboard is current, and no way for a technician
to tick off where they are.

A guide is stored as an ordered list of typed blocks rather than a blob of
HTML, because three of the four things this feature exists for depend on it. A
tickable step needs a stable id to be remembered; dark mode needs semantic
markup rather than pasted colours; and print needs to know where a step begins
so it does not split one across a page. Free HTML gives none of that and lets
pasted styling fight the theme.

Inline text is a restricted Markdown subset - bold, italic, inline code, links,
line breaks - escaped BEFORE formatting, so nothing an author pasted can reach
the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the
theme decides how it looks. That is what makes a guide written next year match
one written today, and dark mode work without an author thinking about it.

DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes
something writes an immutable revision; and a separate pointer says which
revision READERS get. Without that pointer, editing a live procedure changes it
under a technician who is halfway through following it. Publishing promotes an
existing revision rather than snapshotting, so a publish with unsaved edits
cannot ship a version nobody reviewed. Restoring writes a new revision with old
content, so the record of what people were told in between survives.

Saves dedup on a content hash covering title, summary, tags and blocks, so an
autosave that changed nothing writes no version - otherwise the history fills
with identical entries and hides the three edits that mattered.

Progress lives in the browser, not the database: shopfloor PCs share a login, so
per-user ticks would show one technician's progress to the next person on that
bay, which reads as "already done". Keyed by guide and version, because step 4
of version 3 is not step 4 of version 2.

Five permissions, and the split between edit and publish is the point: a
technician can write a procedure up while somebody else decides it is fit for
the floor. create-without-edit works too - an author may edit their own guide
until it is published, after which other people are following it.
2026-08-26 10:24:26 -04:00

221 lines
7.3 KiB
Vue

<template>
<div class="gv">
<div v-if="loading" class="gv-loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<template v-else-if="guide">
<div class="gv-head">
<div class="gv-headmain">
<h1>{{ guide.title }}</h1>
<p v-if="guide.summary" class="gv-summary">{{ guide.summary }}</p>
<div class="gv-meta">
<span v-if="guide.isdraftpreview" class="gv-badge gv-draft">Draft preview</span>
<span v-else class="gv-badge">Version {{ guide.revision }}</span>
<span v-for="tag in guide.tags" :key="tag" class="gv-tag"
@click="$router.push({ name: 'guides', query: { tag } })">{{ tag }}</span>
</div>
</div>
<div class="gv-actions">
<button type="button" class="gv-btn" @click="print">Print</button>
<router-link v-if="guide.canedit" class="gv-btn"
:to="{ name: 'guide-edit', params: { guideid: guide.guideid } }">
Edit
</router-link>
</div>
</div>
<!-- An editor needs to know there is newer work than what readers see,
without leaving the page to find out. -->
<div v-if="guide.hasunpublishedchanges && guide.canedit" class="gv-notice">
There are unpublished changes. Readers are seeing version {{ guide.revision }}.
</div>
<div v-if="totalChecks" class="gv-progress">
<div class="gv-progressbar">
<div class="gv-progressfill" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="gv-progresstext">{{ tickedCount }} of {{ totalChecks }} done</span>
<button v-if="tickedCount" type="button" class="gv-reset" @click="resetProgress">
Reset
</button>
</div>
<GuideBlocks :blocks="guide.blocks" :interactive="true"
:ticked="ticked" @toggle="toggle" />
<div class="gv-foot">
<span>Version {{ guide.revision }}</span>
<span v-if="guide.publishedat">Published {{ formatDate(guide.publishedat) }}</span>
<span class="gv-printedon">Printed {{ today }}</span>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import GuideBlocks from '../components/GuideBlocks.vue'
const route = useRoute()
const guide = ref(null)
const loading = ref(true)
const error = ref('')
const ticked = ref({})
const today = new Date().toLocaleDateString()
/**
* Progress lives in the BROWSER, not the database.
*
* Shopfloor PCs share a login. Storing progress per user would mean one
* technician's ticks appearing for the next person on that bay, which is worse
* than useless on a procedure - it reads as "already done". Per browser, each
* station keeps its own place, and the Reset button is right there.
*
* Keyed by guide id and version: a new version invalidates old ticks, because
* step 4 of version 3 is not step 4 of version 2.
*/
function storageKey () {
return guide.value
? 'guides.progress.' + guide.value.guideid + '.v' + guide.value.revision
: null
}
function loadProgress () {
const key = storageKey()
if (!key) return
try {
ticked.value = JSON.parse(window.localStorage.getItem(key) || '{}')
} catch (err) {
ticked.value = {}
}
}
function saveProgress () {
const key = storageKey()
if (!key) return
try {
window.localStorage.setItem(key, JSON.stringify(ticked.value))
} catch (err) {
// Private window, or storage disabled. The checkboxes still work for this
// visit; they just will not be remembered. Not worth an error message.
}
}
function toggle (itemid, checked) {
if (checked) ticked.value = { ...ticked.value, [itemid]: true }
else {
const next = { ...ticked.value }
delete next[itemid]
ticked.value = next
}
saveProgress()
}
function resetProgress () {
ticked.value = {}
saveProgress()
}
const totalChecks = computed(() => {
let total = 0
for (const block of guide.value?.blocks || []) {
if (block.type === 'checklist') total += (block.items || []).length
}
return total
})
const tickedCount = computed(() => Object.keys(ticked.value).length)
const progressPercent = computed(() =>
totalChecks.value ? Math.round((tickedCount.value / totalChecks.value) * 100) : 0)
function formatDate (value) {
return value ? new Date(value).toLocaleDateString() : ''
}
function print () {
window.print()
}
async function load () {
loading.value = true
error.value = ''
try {
const response = await api.get('/guides/' + route.params.slug)
guide.value = response.data?.data || response.data
loadProgress()
} catch (err) {
error.value = apiError(err, 'Could not load this guide')
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => route.params.slug, load)
</script>
<style scoped>
.gv { max-width: 60rem; }
.gv-head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; }
.gv-headmain { flex: 1; }
.gv-head h1 { margin: 0 0 0.3rem; }
.gv-summary { color: var(--text-light); margin: 0 0 0.5rem; }
.gv-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.gv-badge {
font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 3px;
border: 1px solid var(--border); color: var(--text-light);
}
.gv-draft { border-color: var(--warning, #d9822b); color: var(--warning, #d9822b); }
.gv-tag {
font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px;
background: var(--surface-2, rgba(127,127,127,0.1)); cursor: pointer;
}
.gv-tag:hover { color: var(--primary); }
.gv-actions { display: flex; gap: 0.4rem; }
.gv-btn {
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
color: var(--primary); background: transparent; border-radius: 4px;
cursor: pointer; text-decoration: none;
}
.gv-btn:hover { background: var(--primary); color: #fff; }
.gv-notice {
border-left: 4px solid var(--warning, #d9822b);
background: var(--surface-2, rgba(127,127,127,0.08));
padding: 0.5rem 0.8rem; margin: 0.5rem 0 1rem; font-size: 0.85rem;
}
.gv-progress { display: flex; align-items: center; gap: 0.7rem; margin: 1rem 0 1.4rem; }
.gv-progressbar {
flex: 1; height: 6px; border-radius: 3px; overflow: hidden;
background: var(--surface-2, rgba(127,127,127,0.15));
}
.gv-progressfill { height: 100%; background: var(--primary); transition: width 0.2s; }
.gv-progresstext { font-size: 0.78rem; color: var(--text-light); white-space: nowrap; }
.gv-reset {
font-size: 0.72rem; background: none; border: none; color: var(--text-light);
cursor: pointer; text-decoration: underline;
}
.gv-foot {
margin-top: 2rem; padding-top: 0.6rem; border-top: 1px solid var(--border);
display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-light);
}
.gv-printedon { display: none; }
@media print {
/* A printed guide is a worksheet: no controls, everything visible, and the
footer says which version it was so a stale printout on a noticeboard can
be spotted. */
.gv-actions, .gv-progress, .gv-notice, .gv-reset { display: none !important; }
.gv-printedon { display: inline; }
.gv { max-width: none; }
.gv-head h1 { font-size: 1.6rem; }
.gv-foot { border-top: 1px solid #999; }
}
</style>