Files
shopdb-flask/frontend/src/views/settings/SettingsLayout.vue
cproudlock 1672e349e5
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Collector auto-links measuring tools for metrology PCs; settings rail cleanup
Metrology PCs (CMM, Keyence, Genspect, wax-and-trace imaging pc-types) drive
an attached measuring instrument. The PC itself stays a shopfloor PC, but the
collector now models the instrument:

- New METROLOGY_TOOL_MAP (pctypemap.py) maps those pc-types to a
  MeasuringToolType (CMM, Vision System, Genspect, Form Tracer).
- ComputersPlugin._sync_measuringtool_link creates the MeasuringTool asset
  once and a directional PC->tool "controls" relationship, tagged
  collector:measuringtool. Idempotent (re-push reuses, no duplicate asset) and
  self-archiving (a PC re-imaged to a non-metrology type deactivates the link
  but keeps the asset and any calibration history). Mirrors the printer-link
  pattern. The MeasuringToolType is created on demand if not seeded.
- 4 tests: create+link, idempotent re-push, non-metrology skip, repurpose
  archives. Non-metrology PCs never warn about a missing controls type.

Settings rail cleanup:
- Collapsible groups so the 13-group rail fits without scrolling (1511px ->
  488px). The group containing the current page expands; the rest collapse.
  CSS-drawn caret (ASCII source, no Unicode). Empty groups never render, in
  both the rail and the landing page.
- Measuring Tools group placed with the other asset groups (right after
  Machines) instead of appended last; empty placeholder positions the
  plugin-contributed cards.
- Operating Systems moved from PCs to General Reference: OS is cross-asset
  (PCs, machines, measuring tools, network devices all run one).

Plus docs/proposals/ge-enforce-plugin.md: a planning doc for refactoring
GE-Enforce/DSC into a shopdb plugin (manifest as shopdb data, payloads on
SMB/HTTP/inline), grounded in the real manifest schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:29:08 -04:00

222 lines
5.5 KiB
Vue

<template>
<div class="settings-shell">
<!-- Left rail: grouped, searchable nav that stays put while the right pane swaps -->
<aside class="settings-rail">
<h1 class="rail-title">Settings</h1>
<div class="rail-search">
<input v-model="search" type="text" class="search-input"
placeholder="Search settings..." />
</div>
<nav class="rail-nav">
<template v-for="group in visibleGroups" :key="group.title">
<button
type="button"
class="rail-group-heading"
:class="{ collapsed: !isExpanded(group.title) }"
@click="toggleGroup(group.title)"
>
<span class="rail-chevron" aria-hidden="true"></span>
{{ group.title }}
</button>
<template v-if="isExpanded(group.title)">
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="rail-link"
:class="{ active: isActive(card.to) }"
>
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
<span class="rail-label">{{ card.title }}</span>
</router-link>
</template>
</template>
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
</nav>
</aside>
<!-- Right pane: the selected settings page renders here -->
<section class="settings-content">
<router-view />
</section>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSettingsCatalog } from '../../composables/settingsCatalog'
const route = useRoute()
// Core settings groups merged with plugin-contributed cards (ADR-010).
const { groups } = useSettingsCatalog()
const search = ref('')
// Filter the rail by title/description; drop groups that end up empty.
const visibleGroups = computed(() => {
const term = search.value.trim().toLowerCase()
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
// disabled) so a bare heading never renders.
if (!term) return groups.value.filter(g => g.cards.length)
return groups.value
.map(g => ({
title: g.title,
cards: g.cards.filter(c =>
c.title.toLowerCase().includes(term) ||
c.description.toLowerCase().includes(term)),
}))
.filter(g => g.cards.length)
})
// A rail link is active when the current path matches its base path.
// Every settings section is now its own page, so a plain path compare suffices.
function isActive(to) {
const base = to.split('?')[0]
return route.path === base
}
// Collapsible groups so the 13-section rail fits without scrolling: only the
// group containing the current page is open; the user can toggle others.
// Searching forces every matching group open.
const groupOfActive = computed(() => {
for (const g of groups.value) {
if (g.cards.some(c => isActive(c.to))) return g.title
}
return groups.value[0]?.title
})
const manualToggles = ref({})
function toggleGroup(title) {
manualToggles.value = { ...manualToggles.value, [title]: !isExpanded(title) }
}
function isExpanded(title) {
if (search.value.trim()) return true
if (title in manualToggles.value) return manualToggles.value[title]
return title === groupOfActive.value
}
</script>
<style scoped>
.settings-shell {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.settings-rail {
flex: 0 0 250px;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.rail-title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.rail-search {
margin-bottom: 0.5rem;
}
.search-input {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.85rem;
}
.rail-group-heading {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
margin: 0.35rem 0 0.15rem 0;
padding: 0.2rem 0;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.rail-group-heading:hover {
color: var(--text);
}
.rail-group-heading:first-child {
margin-top: 0;
}
/* CSS-drawn caret (ASCII-only source): a right-pointing triangle that
rotates down when the group is expanded. */
.rail-chevron {
width: 0;
height: 0;
flex: 0 0 auto;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid currentColor;
transition: transform 0.12s ease;
}
.rail-group-heading:not(.collapsed) .rail-chevron {
transform: rotate(90deg);
}
.rail-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.55rem;
border-radius: 6px;
text-decoration: none;
color: var(--text);
font-size: 0.86rem;
border-left: 2px solid transparent;
}
.rail-link:hover {
background: var(--bg);
}
.rail-link.active {
background: var(--bg);
border-left-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
.rail-icon {
display: inline-flex;
color: var(--text-light);
}
.rail-link.active .rail-icon {
color: var(--primary);
}
.rail-label {
line-height: 1.2;
}
.rail-empty {
color: var(--text-light);
font-size: 0.85rem;
}
.settings-content {
flex: 1 1 auto;
min-width: 0;
}
@media (max-width: 820px) {
.settings-shell {
flex-direction: column;
}
.settings-rail {
position: static;
flex-basis: auto;
width: 100%;
max-height: none;
}
}
</style>