Files
shopdb-flask/plugins/printedparts/frontend/views/PrintedPartsSettings.vue
cproudlock af9a3b190b ADR-013 Phase 4: frontend staging machinery + relocate printedparts; fix router crash
The staging step that makes lean per-site frontend builds possible, plus the
first plugin relocated as the pilot.

- scripts/stage-frontend.mjs: copies each chosen plugin's plugins/<name>/frontend/
  into frontend/src/.plugins-staged/<name>/ and codegens routes.gen.js. Plugin
  selection via SITE_PLUGINS (comma-separated); empty = all plugins that have a
  frontend/ (the full build). Wired as npm predev/prebuild; outputs gitignored.
- Router imports routes.gen.js and merges staged routes with the in-tree
  ./routes/*.js glob - dual-location during the transition.
- printedparts relocated: its 6 views (list/detail/form/kiosk + the settings and
  labels views from the shared dirs) moved into plugins/printedparts/frontend/
  views/, core imports rewritten to the @/ alias; routes.js is the self-contained
  route module. Its old in-tree route file is removed.

Also fixes a crash the previous commit (37c764b) shipped: slides.js exports only
`toplevel` (its child routes live in core.js), so the router's
flatMap(m => m.default) produced an undefined child and threw
"Cannot read properties of undefined (reading 'path')" at load - the whole SPA
went blank. Guarded with `m.default || []`. (The earlier "print pages are blank"
reading was this crash, not page nature.)

Verified live: /machines renders again; the relocated /printedparts list renders
identically from the staged plugin frontend; SITE_PLUGINS=machines excludes
printedparts from routes.gen. Build (via npm, runs stage) + vitest + naming green.
2026-07-18 23:42:43 -04:00

178 lines
5.9 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</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>Item code prefix</label>
<input v-model="values.printedparts_code_prefix" type="text"
class="form-control" maxlength="8" />
<p class="field-hint">
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
Changing it does not rename existing items.
</p>
</div>
<div class="form-group">
<label>Default low-stock threshold</label>
<input v-model.number="values.printedparts_default_threshold"
type="number" min="0" class="form-control" />
<p class="field-hint">Seed value for new items; each item can override.</p>
</div>
<div class="form-group">
<label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
<option value="deny">Deny - refuse badges with no directory match</option>
<option value="allow">Allow - record the SSO with no name</option>
</select>
</div>
<div class="form-group">
<label>Alert shopdb users</label>
<div class="user-picker">
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
<input type="checkbox" :value="String(candidate.userid)"
v-model="selectedUserids" />
<span>{{ candidate.username }}</span>
<span class="user-email">{{ candidate.email }}</span>
</label>
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
</div>
<p class="field-hint">
Selected users receive low-stock alerts at their account email.
</p>
</div>
<div class="form-group">
<label>Alert roles</label>
<div class="user-picker">
<label v-for="role in roles" :key="role.roleid" class="user-row">
<input type="checkbox" :value="String(role.roleid)"
v-model="selectedRoleids" />
<span>{{ role.rolename }}</span>
<span class="user-email">{{ role.description }}</span>
</label>
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
</div>
<p class="field-hint">
Every active member of a selected role receives low-stock alerts.
</p>
</div>
<div class="form-group">
<label>Additional alert emails</label>
<input v-model="values.printedparts_alert_email" type="text"
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
<p class="field-hint">
Comma-separated. Empty uses the site-wide alert recipients
(Settings &gt; System &gt; Email). Alerts fire once when an item
crosses its threshold; restocking above re-arms.
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi, usersApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_default_threshold',
'printedparts_unknown_badge',
'printedparts_alert_email',
'printedparts_alert_userids',
'printedparts_alert_roleids'
]
const values = ref({
printedparts_code_prefix: '3DP',
printedparts_default_threshold: 5,
printedparts_unknown_badge: 'deny',
printedparts_alert_email: '',
printedparts_alert_userids: '',
printedparts_alert_roleids: ''
})
const users = ref([])
const selectedUserids = ref([])
const roles = ref([])
const selectedRoleids = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'printedparts' })
const rows = response.data.data || []
for (const row of rows) {
if (KEYS.includes(row.key)) values.value[row.key] = row.value
}
values.value.printedparts_default_threshold =
parseInt(values.value.printedparts_default_threshold, 10) || 0
selectedUserids.value = (values.value.printedparts_alert_userids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const usersResponse = await usersApi.list()
users.value = (usersResponse.data.data || []).filter(
candidate => candidate.isactive && candidate.email)
selectedRoleids.value = (values.value.printedparts_alert_roleids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const rolesResponse = await usersApi.roles.list()
roles.value = rolesResponse.data.data || []
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
for (const key of KEYS) {
await settingsApi.update(key, String(values.value[key] ?? ''))
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.user-picker {
max-height: 12rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0.35rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.user-email { color: var(--text-light); font-size: 0.85rem; }
</style>