Navigation sections declared, guide editor drag + references, USB settings, permission refresh
This commit is contained in:
@@ -75,13 +75,25 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
},
|
||||
|
||||
// Re-read the signed-in user from the server, permissions included.
|
||||
//
|
||||
// The login response is the ONLY thing that used to populate this, and it
|
||||
// is cached in localStorage - so granting somebody a permission did nothing
|
||||
// visible until they happened to log out and back in. They were told they
|
||||
// had rights, the button stayed hidden, and nothing explained why. Called
|
||||
// on app boot so a permission change lands on the next page load.
|
||||
//
|
||||
// Only a 401 logs out. A transient failure - API restarting, network blip -
|
||||
// must not throw somebody out of a session they still hold; the cached user
|
||||
// is stale, not invalid.
|
||||
async fetchUser() {
|
||||
try {
|
||||
const response = await authApi.me()
|
||||
this.user = response.data.data
|
||||
await this.enrichFromDirectory()
|
||||
localStorage.setItem('user', JSON.stringify(this.user))
|
||||
} catch (error) {
|
||||
this.logout()
|
||||
if (error.response?.status === 401) this.logout()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -189,14 +189,33 @@ const defaultNav = [
|
||||
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
|
||||
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
|
||||
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
|
||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
|
||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
|
||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
|
||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
|
||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
|
||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets' },
|
||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets' },
|
||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets' },
|
||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets' },
|
||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets' },
|
||||
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
|
||||
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 },
|
||||
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 },
|
||||
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information' },
|
||||
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information' },
|
||||
]
|
||||
|
||||
// The canonical sidebar sections, in the order they render. A nav item DECLARES
|
||||
// which one it belongs to; nothing is inferred.
|
||||
//
|
||||
// Sections used to be derived from the position number - 10-29 became Assets,
|
||||
// 30-49 became Information - so a plugin landed in whichever bucket its number
|
||||
// happened to fall in. That is how USB Devices, an asset, came to sit under
|
||||
// Information next to GE-Enforce and Tech Tools, and why two plugins picking
|
||||
// the same number silently reordered each other. A number is for ORDER; it was
|
||||
// never a statement about meaning.
|
||||
//
|
||||
// An item with no section (or an unknown one) stays in the unlabelled group at
|
||||
// the top, which is the safe default: it appears, it is just not filed.
|
||||
const NAV_SECTIONS = [
|
||||
{ key: null, label: null },
|
||||
{ key: 'assets', label: 'Assets' },
|
||||
{ key: 'information', label: 'Information' },
|
||||
{ key: 'fleet', label: 'Fleet' },
|
||||
]
|
||||
|
||||
function buildNavItems(items) {
|
||||
@@ -206,29 +225,26 @@ function buildNavItems(items) {
|
||||
const visible = items.filter(item =>
|
||||
!item.permission || authStore.hasPermission(item.permission))
|
||||
|
||||
// Sort by position
|
||||
const sorted = [...visible].sort((a, b) => (a.position || 99) - (b.position || 99))
|
||||
const known = new Set(NAV_SECTIONS.map(s => s.key).filter(Boolean))
|
||||
const bucket = item => {
|
||||
const declared = (item.section || '').toLowerCase()
|
||||
return known.has(declared) ? declared : null
|
||||
}
|
||||
|
||||
// Assign section headers based on position ranges
|
||||
const result = []
|
||||
let currentSection = null
|
||||
|
||||
for (const item of sorted) {
|
||||
let section = null
|
||||
if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') {
|
||||
section = 'Assets'
|
||||
currentSection = 'Assets'
|
||||
} else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) {
|
||||
if (currentSection !== 'Information') {
|
||||
section = 'Information'
|
||||
currentSection = 'Information'
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
section,
|
||||
iconComponent: iconMap[item.icon] || null
|
||||
for (const { key, label } of NAV_SECTIONS) {
|
||||
const inSection = visible
|
||||
.filter(item => bucket(item) === key)
|
||||
// ?? not ||: position 0 is a real position, and `0 || 99` is 99, which
|
||||
// sent Dashboard - the only item that uses 0 - to the end of its group.
|
||||
.sort((a, b) => (a.position ?? 99) - (b.position ?? 99))
|
||||
inSection.forEach((item, index) => {
|
||||
result.push({
|
||||
...item,
|
||||
// Only the first item in a section carries its heading.
|
||||
section: index === 0 ? label : null,
|
||||
iconComponent: iconMap[item.icon] || null,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -236,6 +252,11 @@ function buildNavItems(items) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Refresh the signed-in user (and their permissions) on every app load. The
|
||||
// login payload is cached in localStorage, so without this a permission
|
||||
// granted mid-session stays invisible until the person logs out and back in.
|
||||
if (authStore.isAuthenticated) authStore.fetchUser()
|
||||
|
||||
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
||||
// reload on a screen nobody touches.
|
||||
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
||||
|
||||
@@ -1653,6 +1653,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'icon': 'desktop',
|
||||
'route': '/pcs',
|
||||
'position': 15,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ class GeEnforcePlugin(BasePlugin):
|
||||
'icon': 'shield',
|
||||
'route': '/geenforce/manifests',
|
||||
'position': 46,
|
||||
'section': 'fleet',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -68,12 +68,71 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Reference picker -->
|
||||
<div v-if="refOpen" class="ge-refmask" @click.self="refOpen = false">
|
||||
<div class="ge-ref">
|
||||
<div class="ge-refhead">
|
||||
<strong>Link to a guide or article</strong>
|
||||
<button type="button" @click="refOpen = false"><X :size="16" /></button>
|
||||
</div>
|
||||
<input ref="refInput" v-model="refQuery" class="ge-refsearch"
|
||||
placeholder="Search by title..." @input="searchReferences">
|
||||
<p v-if="refLoading" class="ge-hint">Searching...</p>
|
||||
<p v-else-if="!refResults.length" class="ge-hint">
|
||||
{{ refQuery ? 'Nothing matches that.' : 'Type to search.' }}
|
||||
</p>
|
||||
<ul v-else class="ge-reflist">
|
||||
<li v-for="item in refResults" :key="item.kind + item.id">
|
||||
<button type="button" @click="insertReference(item)">
|
||||
<span class="ge-refkind">{{ item.kind }}</span>
|
||||
<span class="ge-reftitle">{{ item.title }}</span>
|
||||
<small v-if="item.summary">{{ item.summary }}</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
||||
<div class="ge-editor">
|
||||
<div v-for="(block, index) in blocks" :key="block.id"
|
||||
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
|
||||
@focusin="focused = block.id">
|
||||
<!-- Insert point ABOVE each section.
|
||||
The type palette used to live only at the bottom of the whole
|
||||
guide, so adding a step in the middle meant adding it at the end
|
||||
and walking it up one press at a time. The insert bar puts the
|
||||
palette where the section is actually going. -->
|
||||
<div v-for="(block, index) in blocks" :key="block.id">
|
||||
<div class="ge-insert" :class="{ 'ge-insertopen': insertAt === index }">
|
||||
<button type="button" class="ge-insertbtn"
|
||||
:title="'Add a section here'"
|
||||
@click="insertAt = insertAt === index ? null : index">
|
||||
<Plus :size="14" /> Add section
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="insertAt === index" class="ge-palette">
|
||||
<button v-for="type in blocktypes" :key="type" type="button"
|
||||
@click="add(type, index)">{{ type }}</button>
|
||||
<label class="ge-upload">
|
||||
image
|
||||
<input type="file" accept="image/*" hidden
|
||||
@change="event => onFilePick(event, index)">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ge-block"
|
||||
:class="{ 'ge-focused': focused === block.id,
|
||||
'ge-dragging': dragIndex === index,
|
||||
'ge-dragover': dragOver === index }"
|
||||
@focusin="focused = block.id"
|
||||
@dragover.prevent="dragOver = index"
|
||||
@dragleave="dragOver === index && (dragOver = null)"
|
||||
@drop.prevent="dropOn(index)">
|
||||
<div class="ge-blockbar">
|
||||
<!-- Dragging is on the HANDLE, not the whole block: a draggable
|
||||
container swallows text selection inside its own textarea. -->
|
||||
<span class="ge-grip" draggable="true" title="Drag to move"
|
||||
@dragstart="startDrag(index, $event)" @dragend="endDrag">
|
||||
<GripVertical :size="14" />
|
||||
</span>
|
||||
<span class="ge-type">{{ block.type }}</span>
|
||||
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
|
||||
<option value="info">info</option>
|
||||
@@ -92,16 +151,23 @@
|
||||
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
|
||||
<button type="button" title="Code" @click="wrap(block, '`')"><></button>
|
||||
<button type="button" title="Link" @click="makeLink(block)">link</button>
|
||||
<!-- Linking to another guide or a KB article by hand means
|
||||
knowing its slug or id. This finds it by name and writes the
|
||||
link, so a procedure can point at the one before it. -->
|
||||
<button type="button" title="Reference a guide or KB article"
|
||||
@click="openReference(block)"><Link2 :size="14" /></button>
|
||||
</div>
|
||||
|
||||
<div class="ge-blockbtns">
|
||||
<button type="button" title="Move up" @click="move(index, -1)"
|
||||
:disabled="index === 0">^</button>
|
||||
:disabled="index === 0"><ArrowUp :size="14" /></button>
|
||||
<button type="button" title="Move down" @click="move(index, 1)"
|
||||
:disabled="index === blocks.length - 1">v</button>
|
||||
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
|
||||
:disabled="index === blocks.length - 1"><ArrowDown :size="14" /></button>
|
||||
<button type="button" title="Duplicate" @click="duplicate(index)">
|
||||
<Copy :size="14" />
|
||||
</button>
|
||||
<button type="button" class="ge-del" title="Remove"
|
||||
@click="remove(index)">x</button>
|
||||
@click="remove(index)"><X :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,14 +224,24 @@
|
||||
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ge-add">
|
||||
<span>Add:</span>
|
||||
<!-- The end of the guide gets the same control as every gap between
|
||||
sections, so there is one way to add a section, not two. -->
|
||||
<div class="ge-insert ge-insertend"
|
||||
:class="{ 'ge-insertopen': insertAt === blocks.length }">
|
||||
<button type="button" class="ge-insertbtn"
|
||||
@click="insertAt = insertAt === blocks.length ? null : blocks.length">
|
||||
<Plus :size="14" /> Add section
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="insertAt === blocks.length" class="ge-palette">
|
||||
<button v-for="type in blocktypes" :key="type" type="button"
|
||||
@click="add(type)">{{ type }}</button>
|
||||
@click="add(type, blocks.length)">{{ type }}</button>
|
||||
<label class="ge-upload">
|
||||
image
|
||||
<input type="file" accept="image/*" hidden @change="onFilePick">
|
||||
<input type="file" accept="image/*" hidden
|
||||
@change="event => onFilePick(event, blocks.length)">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -209,6 +285,8 @@ import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
import { templates } from '../utils/templates.js'
|
||||
import { proof, applyFix } from '../utils/proof.js'
|
||||
import { useGuideImages } from '../utils/images.js'
|
||||
import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next'
|
||||
import { knowledgebaseApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -220,6 +298,17 @@ const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
|
||||
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
|
||||
slug: '', ispublished: false })
|
||||
const blocks = ref([])
|
||||
// Which gap the type palette is open in. null = closed; blocks.length = the end.
|
||||
const insertAt = ref(null)
|
||||
const dragIndex = ref(null)
|
||||
const dragOver = ref(null)
|
||||
const refOpen = ref(false)
|
||||
const refQuery = ref('')
|
||||
const refResults = ref([])
|
||||
const refLoading = ref(false)
|
||||
const refInput = ref(null)
|
||||
let refBlock = null
|
||||
let refTimer = null
|
||||
const revisions = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
@@ -288,17 +377,111 @@ function insertIndex () {
|
||||
return index === -1 ? blocks.value.length : index + 1
|
||||
}
|
||||
|
||||
function add (type) {
|
||||
// `at` is an explicit gap from an insert bar. Without one, fall back to
|
||||
// "after whatever is focused", which is what the old bottom palette did.
|
||||
function add (type, at = null) {
|
||||
const block = { id: newid(), type }
|
||||
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
||||
if (type === 'callout') block.variant = 'info'
|
||||
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
||||
const at = insertIndex()
|
||||
blocks.value.splice(at, 0, block)
|
||||
const index = at === null ? insertIndex() : at
|
||||
blocks.value.splice(index, 0, block)
|
||||
insertAt.value = null
|
||||
focused.value = block.id
|
||||
nextTick(() => textareas[block.id]?.focus())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- dragging ---
|
||||
function startDrag (index, event) {
|
||||
dragIndex.value = index
|
||||
// Firefox will not start a drag without payload, and 'move' is what the
|
||||
// cursor should say.
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ }
|
||||
}
|
||||
|
||||
function endDrag () {
|
||||
dragIndex.value = null
|
||||
dragOver.value = null
|
||||
}
|
||||
|
||||
function dropOn (index) {
|
||||
const from = dragIndex.value
|
||||
endDrag()
|
||||
if (from === null || from === index) return
|
||||
const [moved] = blocks.value.splice(from, 1)
|
||||
blocks.value.splice(index, 0, moved)
|
||||
focused.value = moved.id
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- references ---
|
||||
function openReference (block) {
|
||||
refBlock = block
|
||||
refOpen.value = true
|
||||
refQuery.value = ''
|
||||
refResults.value = []
|
||||
nextTick(() => refInput.value?.focus())
|
||||
}
|
||||
|
||||
function searchReferences () {
|
||||
clearTimeout(refTimer)
|
||||
const term = refQuery.value.trim()
|
||||
if (!term) { refResults.value = []; return }
|
||||
refTimer = setTimeout(async () => {
|
||||
refLoading.value = true
|
||||
try {
|
||||
const results = []
|
||||
// Guides first: a procedure most often points at another procedure.
|
||||
try {
|
||||
const response = await api.get('/guides', { params: { q: term } })
|
||||
for (const guide of (response.data?.data || [])) {
|
||||
if (!guide.ispublished) continue // never link readers at a draft
|
||||
results.push({ kind: 'guide', id: guide.guideid, title: guide.title,
|
||||
summary: guide.summary, url: `/guides/${guide.slug}` })
|
||||
}
|
||||
} catch (err) { /* guides always exist here, but never break the picker */ }
|
||||
|
||||
// The knowledge base is a separate plugin and may not be installed at
|
||||
// this site (ADR-009). Its absence must cost nothing but its results.
|
||||
try {
|
||||
const response = await knowledgebaseApi.list({ search: term, perpage: 20 })
|
||||
const rows = response.data?.data || []
|
||||
for (const article of (Array.isArray(rows) ? rows : rows.items || [])) {
|
||||
results.push({ kind: 'article', id: article.articleid || article.id,
|
||||
title: article.title,
|
||||
summary: article.summary || article.description,
|
||||
url: `/knowledgebase/${article.articleid || article.id}` })
|
||||
}
|
||||
} catch (err) { /* KB plugin absent or disabled */ }
|
||||
|
||||
refResults.value = results.slice(0, 25)
|
||||
} finally {
|
||||
refLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function insertReference (item) {
|
||||
// An in-app path, not an absolute URL: the link has to keep working when the
|
||||
// site is reached by another hostname or mounted under a subpath.
|
||||
const markdown = `[${item.title}](${item.url})`
|
||||
const area = refBlock ? textareas[refBlock.id] : null
|
||||
if (refBlock && area) {
|
||||
const start = area.selectionStart ?? refBlock.text?.length ?? 0
|
||||
const end = area.selectionEnd ?? start
|
||||
const text = refBlock.text || ''
|
||||
refBlock.text = text.slice(0, start) + markdown + text.slice(end)
|
||||
nextTick(() => {
|
||||
area.focus()
|
||||
const caret = start + markdown.length
|
||||
area.setSelectionRange(caret, caret)
|
||||
})
|
||||
} else if (refBlock) {
|
||||
refBlock.text = (refBlock.text || '') + markdown
|
||||
}
|
||||
refOpen.value = false
|
||||
}
|
||||
|
||||
function duplicate (index) {
|
||||
const source = blocks.value[index]
|
||||
const copy = JSON.parse(JSON.stringify(source))
|
||||
@@ -383,7 +566,7 @@ async function uploadImage (file) {
|
||||
return response.data?.data || response.data
|
||||
}
|
||||
|
||||
async function insertImage (file) {
|
||||
async function insertImage (file, at = null) {
|
||||
error.value = ''
|
||||
status.value = 'Uploading image...'
|
||||
try {
|
||||
@@ -392,7 +575,8 @@ async function insertImage (file) {
|
||||
// for anything that still treats a block as a plain document.
|
||||
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
|
||||
url: image.url, caption: '', alt: '' }
|
||||
blocks.value.splice(insertIndex(), 0, block)
|
||||
blocks.value.splice(at === null ? insertIndex() : at, 0, block)
|
||||
insertAt.value = null
|
||||
focused.value = block.id
|
||||
status.value = ''
|
||||
} catch (err) {
|
||||
@@ -416,9 +600,9 @@ function onDrop (event) {
|
||||
if (file) insertImage(file)
|
||||
}
|
||||
|
||||
function onFilePick (event) {
|
||||
function onFilePick (event, at = null) {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) insertImage(file)
|
||||
if (file) insertImage(file, at)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
@@ -714,4 +898,77 @@ onMounted(async () => {
|
||||
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); border-radius: 3px; cursor: pointer; }
|
||||
|
||||
/* --- insert bars, drag handle, reference picker -------------------------- */
|
||||
|
||||
/* A quiet line that becomes a button on hover: present when wanted, not
|
||||
competing with the guide's own content the rest of the time. */
|
||||
.ge-insert {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 1.5rem; opacity: 0; transition: opacity 0.12s;
|
||||
}
|
||||
.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; }
|
||||
.ge-insert::before, .ge-insert::after {
|
||||
content: ''; flex: 1; height: 1px; background: var(--border);
|
||||
}
|
||||
.ge-insertbtn {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
margin: 0 0.5rem; padding: 0.15rem 0.6rem;
|
||||
font-size: 0.75rem; color: var(--text-light);
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
background: var(--bg); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); }
|
||||
.ge-palette {
|
||||
display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center;
|
||||
margin: 0 0 0.6rem; padding: 0.5rem;
|
||||
border: 1px solid var(--border); border-radius: 5px;
|
||||
}
|
||||
.ge-palette button, .ge-palette .ge-upload {
|
||||
font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.ge-palette button:hover, .ge-palette .ge-upload:hover {
|
||||
color: var(--primary); border-color: var(--primary);
|
||||
}
|
||||
|
||||
.ge-grip { display: inline-flex; align-items: center; color: var(--text-light);
|
||||
cursor: grab; padding-right: 0.2rem; }
|
||||
.ge-grip:active { cursor: grabbing; }
|
||||
.ge-dragging { opacity: 0.45; }
|
||||
.ge-dragover { border-top: 2px solid var(--primary); }
|
||||
|
||||
.ge-refmask {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 8vh; z-index: 50;
|
||||
}
|
||||
.ge-ref {
|
||||
width: min(34rem, 92vw); max-height: 70vh; overflow: auto;
|
||||
background: var(--bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 1rem;
|
||||
}
|
||||
.ge-refhead { display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 0.6rem; }
|
||||
.ge-refhead button { background: transparent; border: none; color: var(--text-light);
|
||||
cursor: pointer; }
|
||||
.ge-refsearch {
|
||||
width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: transparent; color: var(--text); margin-bottom: 0.6rem;
|
||||
}
|
||||
.ge-reflist { list-style: none; margin: 0; padding: 0; }
|
||||
.ge-reflist button {
|
||||
width: 100%; display: flex; flex-direction: column; gap: 0.15rem;
|
||||
padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: transparent; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.ge-reflist button:hover { border-color: var(--primary); }
|
||||
.ge-refkind {
|
||||
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.ge-reftitle { font-weight: 600; }
|
||||
.ge-reflist small { color: var(--text-light); }
|
||||
</style>
|
||||
|
||||
@@ -75,10 +75,15 @@ class GuidesPlugin(BasePlugin):
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
# Position 8, beside Slides (7), rather than down in the Information
|
||||
# section with the reference lists. A guide is something you go and DO
|
||||
# something with, like a slide deck you put on a screen - not a
|
||||
# catalogue you look things up in.
|
||||
# Position 8, directly under Slides (7), rather than down in the
|
||||
# Information section with the reference lists. A guide is something you
|
||||
# go and DO something with, like a slide deck you put on a screen - not
|
||||
# a catalogue you look things up in.
|
||||
#
|
||||
# Warranties held 8 as well, and a TIE is broken by plugin load order,
|
||||
# so Guides landed underneath it and read as "not under Slides at all".
|
||||
# Warranties moved to 9. Two nav items sharing a position is not a
|
||||
# preference, it is a coin toss.
|
||||
return [
|
||||
{
|
||||
'name': 'Guides',
|
||||
|
||||
@@ -232,6 +232,7 @@ class MachinesPlugin(BasePlugin):
|
||||
'icon': 'cog',
|
||||
'route': '/machines',
|
||||
'position': 10,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ class MeasuringToolsPlugin(BasePlugin):
|
||||
'icon': 'ruler',
|
||||
'route': '/measuringtools',
|
||||
'position': 22,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
<template>
|
||||
<div class="detail-page" v-if="device">
|
||||
<!--
|
||||
The same page-header + header-actions block every other asset detail page
|
||||
uses. This page had no header at all: Edit was tucked inside the hero
|
||||
title row and there was no Print Label, no Retire and no way back to the
|
||||
list, so managing a network device meant looking somewhere different from
|
||||
every other asset type.
|
||||
-->
|
||||
<div class="page-header">
|
||||
<h2>Network Device Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/network_device/${deviceId}`"
|
||||
class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link v-if="authStore.isAuthenticated" :to="`/network/${deviceId}/edit`"
|
||||
class="btn btn-primary">Edit</router-link>
|
||||
<button v-if="authStore.isAuthenticated" @click="confirmRetire"
|
||||
class="btn btn-danger">Retire</button>
|
||||
<router-link to="/network" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-card">
|
||||
<div class="hero-image">
|
||||
<img v-if="device.networkdevice?.imageurl" :src="withBase(device.networkdevice.imageurl)" alt="Model photo" />
|
||||
@@ -10,13 +30,6 @@
|
||||
<div class="hero-content">
|
||||
<div class="hero-title-row">
|
||||
<h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
|
||||
<router-link
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="`/network/${deviceId}/edit`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge" :style="colorStyle(device.statuscolor)">
|
||||
@@ -184,18 +197,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
||||
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
|
||||
Edit Device
|
||||
</router-link>
|
||||
<button @click="confirmDelete" class="btn btn-danger">
|
||||
Delete Device
|
||||
</button>
|
||||
</div>
|
||||
<!-- The manage actions live in the page header, where every other asset
|
||||
detail page puts them. They used to sit down here instead, which is
|
||||
why finding them on a network device meant looking somewhere else. -->
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
@@ -228,6 +232,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
const toast = useToast()
|
||||
|
||||
@@ -293,17 +298,24 @@ function formatDate(dateStr) {
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (confirm(`Are you sure you want to delete ${device.value.networkdevice?.hostname || device.value.assetnumber}?`)) {
|
||||
try {
|
||||
await networkApi.delete(deviceId)
|
||||
router.push('/network')
|
||||
} catch (error) {
|
||||
console.error('Error deleting device:', error)
|
||||
toast.error('Failed to delete device')
|
||||
}
|
||||
// Soft delete, like every other asset type: the record leaves the lists while
|
||||
// its history and relationships stay put, and it can be restored by setting
|
||||
// isactive back. Called Retire rather than Delete for exactly that reason.
|
||||
async function confirmRetire () {
|
||||
const label = device.value?.networkdevice?.hostname
|
||||
|| device.value?.name || device.value?.assetnumber || 'this device'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
+ `History and relationships are kept, and it can be restored.`)) return
|
||||
try {
|
||||
await networkApi.delete(deviceId)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/network')
|
||||
} catch (error) {
|
||||
console.error('Error retiring network device:', error)
|
||||
toast.error(apiError(error, 'Failed to retire network device'))
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -208,6 +208,7 @@ class NetworkPlugin(BasePlugin):
|
||||
'icon': 'network-wired',
|
||||
'route': '/network',
|
||||
'position': 18,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'name': '3D Parts',
|
||||
'icon': 'box',
|
||||
'route': '/printedparts',
|
||||
'position': 46,
|
||||
'position': 27,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -386,6 +386,7 @@ class PrintersPlugin(BasePlugin):
|
||||
'icon': 'printer',
|
||||
'route': '/printers',
|
||||
'position': 20,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ class ToolsPlugin(BasePlugin):
|
||||
'icon': 'wrench',
|
||||
'route': '/tools',
|
||||
'position': 47,
|
||||
'section': 'fleet',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ export default [
|
||||
name: 'usb-edit',
|
||||
component: () => import('./views/USBForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'settings/usb',
|
||||
name: 'settings-usb',
|
||||
component: () => import('./views/USBSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'usb' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
113
plugins/usb/frontend/views/USBSettings.vue
Normal file
113
plugins/usb/frontend/views/USBSettings.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>USB Devices</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_enabled" />
|
||||
<span>Allow the logged-out USB kiosk</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Turns on <code>/usb-kiosk</code>, a full-screen touch page where somebody
|
||||
at the drive cabinet takes and returns a drive without signing in.
|
||||
It is off by default: this is a write nobody has logged in for, so it is
|
||||
a decision a site makes deliberately. While it is off, every kiosk
|
||||
request is refused and the page says so.
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
The kiosk can only move a drive between checked out and checked in. It
|
||||
cannot add, edit or retire one. Each action is attributed to the SSO
|
||||
entered on the screen, and an SSO that matches no employee is refused -
|
||||
a checkout nobody can trace to a person defeats the point of tracking
|
||||
it.
|
||||
</p>
|
||||
<p v-if="values.usb_kiosk_enabled" class="field-hint">
|
||||
<a :href="withBase('/usb-kiosk')" target="_blank">Open the kiosk</a>
|
||||
- also in the sidebar under Displays.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
||||
:disabled="!values.usb_kiosk_enabled" />
|
||||
<span>Let somebody return a drive checked out to another person</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
The kiosk asks first and names the holder, then records the SSO that
|
||||
actually handed the drive back. Turn this off to refuse a mismatch
|
||||
outright - but then a drive left behind by somebody on leave cannot be
|
||||
returned at the kiosk at all, and needs a signed-in user to sort out.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others']
|
||||
|
||||
const values = ref({
|
||||
usb_kiosk_enabled: false,
|
||||
usb_kiosk_allow_return_for_others: true,
|
||||
})
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
function asBool (value) {
|
||||
return String(value).toLowerCase() === 'true' || value === true || value === 1
|
||||
}
|
||||
|
||||
async function load () {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'usb' })
|
||||
for (const row of (response.data.data || [])) {
|
||||
if (KEYS.includes(row.key)) values.value[row.key] = asBool(row.value)
|
||||
}
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load USB settings.'
|
||||
}
|
||||
}
|
||||
|
||||
async function save () {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, values.value[key] ? 'true' : 'false')
|
||||
}
|
||||
message.value = 'Settings saved'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
|
||||
.check-row input { width: auto; }
|
||||
.form-actions { margin-top: 1rem; }
|
||||
</style>
|
||||
@@ -70,6 +70,27 @@ class USBPlugin(BasePlugin):
|
||||
'docs': 'plugins/usb/README.md',
|
||||
}
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""Somewhere to actually turn the kiosk on.
|
||||
|
||||
The kiosk page tells an administrator to enable it in Settings under
|
||||
USB. Without this card that place does not exist, and the instruction
|
||||
is a dead end - the same trap the backups plugin shipped with, where
|
||||
settings existed but only a shell could reach them.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'USB Devices',
|
||||
'to': '/settings/usb',
|
||||
'icon': 'usb',
|
||||
'title': 'USB Settings',
|
||||
'description': 'Logged-out kiosk for taking and returning '
|
||||
'drives, and whether somebody may return a drive '
|
||||
'checked out to another person',
|
||||
'position': 45,
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Kiosk settings.
|
||||
|
||||
@@ -144,7 +165,8 @@ class USBPlugin(BasePlugin):
|
||||
'name': 'USB Devices',
|
||||
'icon': 'usb',
|
||||
'route': '/usb',
|
||||
'position': 45,
|
||||
'position': 25,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class WarrantyPlugin(BasePlugin):
|
||||
'name': 'Warranties',
|
||||
'icon': 'shield',
|
||||
'route': '/warranties',
|
||||
'position': 8,
|
||||
'position': 9,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user