272 lines
8.9 KiB
Vue
272 lines
8.9 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>
|
|
|
|
<style>
|
|
/*
|
|
* PRINT IS ALWAYS LIGHT, whatever the screen theme is.
|
|
*
|
|
* Printing a dark-mode page lays down a full page of toner for the background
|
|
* and then knocks the text out of it in white. On a shopfloor printer that is
|
|
* a cartridge for a handful of guides, and the result is harder to read than
|
|
* the plain version.
|
|
*
|
|
* So the theme tokens are redefined for print only. Every guide element already
|
|
* renders through these variables rather than literal colours, which is what
|
|
* makes this a dozen lines instead of a rewrite - and the same discipline that
|
|
* made dark mode work at all.
|
|
*
|
|
* Deliberately NOT scoped: a scoped block cannot reach :root, and these have to
|
|
* override tokens set on the document element.
|
|
*/
|
|
@media print {
|
|
:root,
|
|
:root[data-theme="dark"],
|
|
:root[data-theme="light"] {
|
|
--text: #000;
|
|
--text-light: #333;
|
|
--border: #999;
|
|
--surface: #fff;
|
|
--surface-2: #fff;
|
|
--primary: #000;
|
|
--info: #666;
|
|
--warning: #666;
|
|
--success: #666;
|
|
}
|
|
|
|
html, body, .app-layout, .main-content {
|
|
background: #fff !important;
|
|
color: #000 !important;
|
|
}
|
|
|
|
/* Backgrounds are dropped rather than printed grey: a callout is identified
|
|
by its rule and its heading, which cost nothing to print. */
|
|
.gb-callout, .gb-code, .gb-codehead, .gb-table th {
|
|
background: #fff !important;
|
|
}
|
|
.gb-callout { border-left: 3px solid #000 !important; }
|
|
.gb-code, .gb-table th, .gb-table td { border-color: #999 !important; }
|
|
|
|
/* Links: the text is what matters on paper, and a blue underline on a mono
|
|
printer is just grey. */
|
|
a { color: #000 !important; text-decoration: underline; }
|
|
}
|
|
</style>
|