Two things a second site ran into. The network device form asked for the map position as two raw numbers, so placing a device meant reading coordinates off another screen and typing them in. Machines, PCs and printers have had a "Set Location on Map" picker all along, and the network API already accepted mapx and mapy - only the form was missing. Same picker, same modal. The 3D parts kiosk hardcoded 'WJ' as the prefix shown before the number box, with a comment inviting whoever needed something else to edit the source. That is West Jefferson's gage-lab tag format and nobody else's, so another site's operators were told to expect letters that are not on their labels. It is now printedparts_label_prefix, set in Settings, defaulting to EMPTY - a site that has not set one sees no prefix rather than inheriting another site's convention. West Jefferson sets it to WJ once. The kiosk hides the prefix entirely when unset and falls back to no prefix if the setting cannot be read, because a cosmetic hint must never stop a kiosk working. Not to be confused with printedparts_code_prefix, which mints item codes like 3DP0042 and was already configurable. That is the code we generate; this is the tag already printed on the label.
210 lines
7.3 KiB
Vue
210 lines
7.3 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>Kiosk label prefix</label>
|
|
<input v-model="values.printedparts_label_prefix" type="text"
|
|
class="form-control" maxlength="8" placeholder="none" />
|
|
<p class="field-hint">
|
|
Leading text on this site's physical labels, shown at the kiosk before the
|
|
number box so operators type only the digits. Leave it empty to show no
|
|
prefix. This is about the printed tags, not the item codes above.
|
|
</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>
|
|
|
|
<div class="form-group">
|
|
<label>Alert support team (Teams webhook)</label>
|
|
<select v-model="values.printedparts_alert_supportteamid" class="form-control">
|
|
<option value="">Site default webhook</option>
|
|
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
|
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
|
</option>
|
|
</select>
|
|
<p class="field-hint">
|
|
Low-stock alerts post to this team's webhook (set on Settings >
|
|
Support Teams). Empty uses the site-wide alert webhook.
|
|
</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, supportteamsApi } from '@/api'
|
|
|
|
const KEYS = [
|
|
'printedparts_code_prefix',
|
|
'printedparts_label_prefix',
|
|
'printedparts_default_threshold',
|
|
'printedparts_unknown_badge',
|
|
'printedparts_alert_email',
|
|
'printedparts_alert_userids',
|
|
'printedparts_alert_roleids',
|
|
'printedparts_alert_supportteamid'
|
|
]
|
|
|
|
const values = ref({
|
|
printedparts_code_prefix: '3DP',
|
|
printedparts_label_prefix: '',
|
|
printedparts_default_threshold: 5,
|
|
printedparts_unknown_badge: 'deny',
|
|
printedparts_alert_email: '',
|
|
printedparts_alert_userids: '',
|
|
printedparts_alert_roleids: '',
|
|
printedparts_alert_supportteamid: ''
|
|
})
|
|
const supportTeams = ref([])
|
|
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 || []
|
|
const teamsResponse = await supportteamsApi.list()
|
|
supportTeams.value = teamsResponse.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>
|