printedparts stage 15: print-file revision history + role-based alerts
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.
This commit is contained in:
@@ -1168,5 +1168,19 @@ export const printedpartsApi = {
|
||||
},
|
||||
kioskTake(data) {
|
||||
return api.post('/printedparts/kiosk/take', data)
|
||||
},
|
||||
listFiles(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}/files`)
|
||||
},
|
||||
uploadFile(printeditemid, file, note) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (note) formData.append('note', note)
|
||||
return api.post(`/printedparts/items/${printeditemid}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
removeFile(fileid) {
|
||||
return api.delete(`/printedparts/files/${fileid}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,57 @@
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Print files</h3>
|
||||
<div class="file-upload-row">
|
||||
<input ref="fileInput" type="file"
|
||||
accept=".stl,.3mf,.gcode,.gco,.bgcode,.step,.stp,.obj,.amf" />
|
||||
<input v-model="fileNote" type="text" class="form-control"
|
||||
placeholder="What changed? (optional)" />
|
||||
<button class="btn btn-primary btn-sm" :disabled="fileUploading"
|
||||
@click="uploadRevision">
|
||||
{{ fileUploading ? 'Uploading...' : 'Upload revision' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="fileError" class="error-message">{{ fileError }}</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rev</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>By</th>
|
||||
<th>Note</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="revision in files" :key="revision.fileid"
|
||||
:class="{ 'current-revision': revision === files[0] }">
|
||||
<td>{{ revision.revision }}</td>
|
||||
<td>
|
||||
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)">
|
||||
{{ revision.filename }}
|
||||
</a>
|
||||
<span v-if="revision === files[0]" class="badge badge-success">current</span>
|
||||
</td>
|
||||
<td>{{ formatSize(revision.filesize) }}</td>
|
||||
<td :title="revision.uploadeddate">{{ revision.uploadedby }}</td>
|
||||
<td>{{ revision.uploadnote || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
@click="removeRevision(revision)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="files.length === 0">
|
||||
<td colspan="6" class="empty-state">No print file uploaded yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Recent transactions</h3>
|
||||
<div class="table-container">
|
||||
@@ -151,6 +202,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
item.value = response.data.data
|
||||
await loadFiles()
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printed item:', loadError)
|
||||
} finally {
|
||||
@@ -158,6 +210,58 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const files = ref([])
|
||||
const fileInput = ref(null)
|
||||
const fileNote = ref('')
|
||||
const fileUploading = ref(false)
|
||||
const fileError = ref('')
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const response = await printedpartsApi.listFiles(route.params.id)
|
||||
files.value = response.data.data || []
|
||||
} catch (filesError) {
|
||||
console.error('Error loading files:', filesError)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadRevision() {
|
||||
const file = fileInput.value?.files?.[0]
|
||||
if (!file) { fileError.value = 'Choose a file first'; return }
|
||||
fileUploading.value = true
|
||||
fileError.value = ''
|
||||
try {
|
||||
await printedpartsApi.uploadFile(route.params.id, file, fileNote.value)
|
||||
fileNote.value = ''
|
||||
fileInput.value.value = ''
|
||||
await loadFiles()
|
||||
} catch (uploadError) {
|
||||
fileError.value =
|
||||
uploadError.response?.data?.data?.error?.message || 'Upload failed'
|
||||
} finally {
|
||||
fileUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRevision(revision) {
|
||||
if (!window.confirm(
|
||||
`Delete revision ${revision.revision} (${revision.filename})?`)) return
|
||||
try {
|
||||
await printedpartsApi.removeFile(revision.fileid)
|
||||
await loadFiles()
|
||||
} catch (removeError) {
|
||||
fileError.value = 'Delete failed'
|
||||
console.error(removeError)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes && bytes !== 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const ledgerOpen = ref(false)
|
||||
const ledgerMode = ref('restock')
|
||||
const ledgerQuantity = ref(null)
|
||||
@@ -233,5 +337,13 @@ function formatDate(value) {
|
||||
<style scoped>
|
||||
.hero-actions { margin-top: 0.75rem; }
|
||||
.qty-out { color: var(--danger); }
|
||||
.file-upload-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.current-revision td { font-weight: 600; }
|
||||
.qty-in { color: var(--success); }
|
||||
</style>
|
||||
|
||||
@@ -49,6 +49,22 @@
|
||||
</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"
|
||||
@@ -76,7 +92,8 @@ const KEYS = [
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids'
|
||||
'printedparts_alert_userids',
|
||||
'printedparts_alert_roleids'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
@@ -84,10 +101,13 @@ const values = ref({
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: ''
|
||||
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('')
|
||||
@@ -106,6 +126,10 @@ onMounted(async () => {
|
||||
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)
|
||||
@@ -118,6 +142,7 @@ async function save() {
|
||||
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] ?? ''))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user