printeditemfiles lands as the plugin's first incremental migration (0002 on the plugin chain - the ADR-008 payoff). Revisions are append-only per item: upload assigns the next number, records the uploader from the JWT, enforces an extension allowlist and a 100 MB cap; download serves the original filename; a permission-gated delete covers wrong-file mistakes. The detail page gains the revision table with a current badge. Unique storedfilename is sized 191 so the index fits MySQL's 767-byte prefix - the per-plugin chain does not apply the core env's ROW_FORMAT hook. Alert recipients gain roles: Role joins the 0.13.0 surface, a role picker on the settings page, and every active member of the selected roles is folded into the deduped recipient list.
178 lines
5.9 KiB
Vue
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 > System > 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>
|