printedparts stage 4: catalog mutations, item photos, detail + form
POST/PUT/DELETE for items: create mints the itemcode from the configured prefix plus the flushed row id, update refuses quantityonhand (ledger-managed - restock/adjust arrive next stage), delete soft-retires. The image upload/serve/delete trio replicates the models.py pattern into instance/printedpartsimages/ with a public GET. PrintedItemDetail follows the unified detail skeleton (hero photo, info list, transaction history table); PrintedItemForm covers create/edit plus photo management on edit.
This commit is contained in:
@@ -1134,5 +1134,24 @@ export const printedpartsApi = {
|
|||||||
},
|
},
|
||||||
get(printeditemid) {
|
get(printeditemid) {
|
||||||
return api.get(`/printedparts/items/${printeditemid}`)
|
return api.get(`/printedparts/items/${printeditemid}`)
|
||||||
|
},
|
||||||
|
create(data) {
|
||||||
|
return api.post('/printedparts/items', data)
|
||||||
|
},
|
||||||
|
update(printeditemid, data) {
|
||||||
|
return api.put(`/printedparts/items/${printeditemid}`, data)
|
||||||
|
},
|
||||||
|
remove(printeditemid) {
|
||||||
|
return api.delete(`/printedparts/items/${printeditemid}`)
|
||||||
|
},
|
||||||
|
uploadImage(printeditemid, file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteImage(printeditemid) {
|
||||||
|
return api.delete(`/printedparts/items/${printeditemid}/image`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +1,133 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="detail-page" v-if="item">
|
<div class="detail-page">
|
||||||
<div class="hero-card">
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
<div class="hero-content">
|
|
||||||
<div class="hero-title-row">
|
<template v-else-if="item">
|
||||||
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Printedparts' }}</h1>
|
<div class="hero-card">
|
||||||
<router-link
|
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
|
||||||
v-if="authStore.isAuthenticated"
|
:alt="item.itemname" class="hero-image" />
|
||||||
:to="`/printedparts/${itemId}/edit`"
|
<div class="hero-content">
|
||||||
class="btn btn-secondary"
|
<h2 class="hero-title">{{ item.itemname }}</h2>
|
||||||
>
|
<div class="hero-meta">
|
||||||
Edit
|
<span class="badge badge-secondary">{{ item.itemcode }}</span>
|
||||||
</router-link>
|
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||||
</div>
|
{{ item.quantityonhand }} on hand
|
||||||
<div class="hero-details">
|
</span>
|
||||||
<div class="detail-item" v-if="item.assetnumber">
|
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
|
||||||
<span class="label">Asset #</span>
|
|
||||||
<span class="value">{{ item.assetnumber }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item" v-if="item.serialnumber">
|
<div class="hero-details">
|
||||||
<span class="label">Serial</span>
|
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
||||||
<span class="value mono">{{ item.serialnumber }}</span>
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
||||||
|
class="btn btn-secondary btn-sm">Edit</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h3 class="section-title">Printedparts Information</h3>
|
<h3 class="section-title">Details</h3>
|
||||||
<div class="info-list">
|
<div class="info-list">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">Example Field</span>
|
<span class="info-label">Item code</span>
|
||||||
<span class="info-value">{{ item.examplefield || '-' }}</span>
|
<span class="info-value">{{ item.itemcode }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Bin location</span>
|
||||||
|
<span class="info-value">{{ item.binlocation || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Quantity on hand</span>
|
||||||
|
<span class="info-value">{{ item.quantityonhand }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Low-stock threshold</span>
|
||||||
|
<span class="info-value">{{ item.lowstockthreshold }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row" v-if="item.printnotes">
|
||||||
|
<span class="info-label">Print notes</span>
|
||||||
|
<span class="info-value">{{ item.printnotes }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-column">
|
||||||
|
<div class="section-card">
|
||||||
|
<h3 class="section-title">Recent transactions</h3>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Qty</th>
|
||||||
|
<th>Who</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="transaction in item.recenttransactions"
|
||||||
|
:key="transaction.transactionid">
|
||||||
|
<td>{{ formatDate(transaction.transactiondate) }}</td>
|
||||||
|
<td>{{ transaction.transactiontype }}</td>
|
||||||
|
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
|
||||||
|
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
|
||||||
|
</td>
|
||||||
|
<td>{{ transaction.employeename || transaction.employeesso }}</td>
|
||||||
|
<td>{{ transaction.reason || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!item.recenttransactions?.length">
|
||||||
|
<td colspan="5" class="empty-state">No transactions yet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-column">
|
<div class="audit-footer">
|
||||||
<div class="section-card">
|
Created {{ formatDate(item.createddate) }} -
|
||||||
<h3 class="section-title">Asset Information</h3>
|
Modified {{ formatDate(item.modifieddate) }}
|
||||||
<div class="info-list">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Asset Number</span>
|
|
||||||
<span class="info-value">{{ item.assetnumber || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Name</span>
|
|
||||||
<span class="info-value">{{ item.name || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Serial Number</span>
|
|
||||||
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
<div v-else class="card">Item not found</div>
|
||||||
<router-link :to="`/printedparts/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
|
|
||||||
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="loading" class="loading-container">
|
|
||||||
<div class="loading">Loading...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="error-container">
|
|
||||||
<p>Record not found</p>
|
|
||||||
<router-link to="/printedparts" class="btn btn-secondary">Back to Printedparts</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import api from '../../api'
|
import { printedpartsApi } from '../../api'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { withBase } from '../../utils/basePath'
|
||||||
|
|
||||||
// local api client. move into src/api/index.js (see
|
|
||||||
// plugins/printedparts/frontend-api-snippet.js) then swap for:
|
|
||||||
// import { printedpartsApi } from '../../api'
|
|
||||||
const printedpartsApi = {
|
|
||||||
list(params = {}) { return api.get('/printedparts', { params }) },
|
|
||||||
get(itemId) { return api.get(`/printedparts/${itemId}`) },
|
|
||||||
create(data) { return api.post('/printedparts', data) },
|
|
||||||
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
|
|
||||||
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const itemId = route.params.id
|
|
||||||
const item = ref(null)
|
const item = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
onMounted(loadItem)
|
onMounted(async () => {
|
||||||
|
|
||||||
async function loadItem() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const response = await printedpartsApi.get(itemId)
|
const response = await printedpartsApi.get(route.params.id)
|
||||||
item.value = response.data.data
|
item.value = response.data.data
|
||||||
} catch (error) {
|
} catch (loadError) {
|
||||||
console.error('Error loading printedparts:', error)
|
console.error('Error loading printed item:', loadError)
|
||||||
item.value = null
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
async function confirmDelete() {
|
function formatDate(value) {
|
||||||
if (confirm('Delete this record?')) {
|
if (!value) return '-'
|
||||||
try {
|
return new Date(value).toLocaleString()
|
||||||
await printedpartsApi.remove(itemId)
|
|
||||||
router.push('/printedparts')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting printedparts:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.mono {
|
.hero-actions { margin-top: 0.75rem; }
|
||||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
.qty-out { color: var(--danger); }
|
||||||
}
|
.qty-in { color: var(--success); }
|
||||||
|
|
||||||
.action-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container,
|
|
||||||
.error-container {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: var(--text-light);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,74 +1,66 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>{{ isEdit ? 'Edit Printedparts' : 'Add Printedparts' }}</h2>
|
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card form-card">
|
<div class="card form-card">
|
||||||
<form @submit.prevent="submitForm">
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<fieldset>
|
|
||||||
<legend>Asset Information</legend>
|
|
||||||
|
|
||||||
<div class="form-row">
|
<form @submit.prevent="save">
|
||||||
<div class="form-group">
|
<div class="form-row">
|
||||||
<label for="assetnumber">Asset Number *</label>
|
<div class="form-group">
|
||||||
<input
|
<label>Name *</label>
|
||||||
id="assetnumber"
|
<input v-model="form.itemname" type="text" class="form-control" required />
|
||||||
v-model="form.assetnumber"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
required
|
|
||||||
:disabled="isEdit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="name">Name</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
v-model="form.name"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<div class="form-row">
|
<label>Bin location</label>
|
||||||
<div class="form-group">
|
<input v-model="form.binlocation" type="text" class="form-control"
|
||||||
<label for="serialnumber">Serial Number</label>
|
placeholder="e.g., Bin A3" />
|
||||||
<input
|
|
||||||
id="serialnumber"
|
|
||||||
v-model="form.serialnumber"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Printedparts Details</legend>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="examplefield">Example Field</label>
|
|
||||||
<input
|
|
||||||
id="examplefield"
|
|
||||||
v-model="form.examplefield"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
||||||
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div class="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<input v-model="form.itemdescription" type="text" class="form-control"
|
||||||
|
maxlength="500" placeholder="Brief description shown on the storefront" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Low-stock threshold</label>
|
||||||
|
<input v-model.number="form.lowstockthreshold" type="number" min="0"
|
||||||
|
class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div v-if="isEdit" class="form-group">
|
||||||
|
<label>Item code</label>
|
||||||
|
<input :value="itemcode" type="text" class="form-control" disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Print notes</label>
|
||||||
|
<textarea v-model="form.printnotes" class="form-control" rows="3"
|
||||||
|
placeholder="Material, print time, slicer file path"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isEdit" class="form-group">
|
||||||
|
<label>Photo</label>
|
||||||
|
<div class="image-row">
|
||||||
|
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
|
||||||
|
<input type="file" accept="image/*" @change="onImagePicked" />
|
||||||
|
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
|
||||||
|
@click="removeImage">Remove photo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||||
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,152 +69,102 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import api from '../../api'
|
import { printedpartsApi } from '../../api'
|
||||||
|
import { withBase } from '../../utils/basePath'
|
||||||
// local api client. move into src/api/index.js (see
|
|
||||||
// plugins/printedparts/frontend-api-snippet.js) then swap for:
|
|
||||||
// import { printedpartsApi } from '../../api'
|
|
||||||
const printedpartsApi = {
|
|
||||||
list(params = {}) { return api.get('/printedparts', { params }) },
|
|
||||||
get(itemId) { return api.get(`/printedparts/${itemId}`) },
|
|
||||||
create(data) { return api.post('/printedparts', data) },
|
|
||||||
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
|
|
||||||
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const itemId = route.params.id
|
const isEdit = computed(() => !!route.params.id)
|
||||||
const isEdit = computed(() => !!itemId)
|
const cancelTarget = computed(() =>
|
||||||
|
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
assetnumber: '',
|
itemname: '',
|
||||||
name: '',
|
itemdescription: '',
|
||||||
serialnumber: '',
|
lowstockthreshold: null,
|
||||||
examplefield: ''
|
binlocation: '',
|
||||||
|
printnotes: ''
|
||||||
})
|
})
|
||||||
|
const itemcode = ref('')
|
||||||
|
const imageurl = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (isEdit.value) {
|
if (!isEdit.value) return
|
||||||
await loadItem()
|
try {
|
||||||
|
const response = await printedpartsApi.get(route.params.id)
|
||||||
|
const item = response.data.data
|
||||||
|
for (const key of Object.keys(form.value)) {
|
||||||
|
form.value[key] = item[key]
|
||||||
|
}
|
||||||
|
itemcode.value = item.itemcode
|
||||||
|
imageurl.value = item.imageurl
|
||||||
|
} catch (loadError) {
|
||||||
|
error.value = 'Could not load the item'
|
||||||
|
console.error(loadError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadItem() {
|
async function save() {
|
||||||
try {
|
|
||||||
const response = await printedpartsApi.get(itemId)
|
|
||||||
const data = response.data.data
|
|
||||||
form.value.assetnumber = data.assetnumber || ''
|
|
||||||
form.value.name = data.name || ''
|
|
||||||
form.value.serialnumber = data.serialnumber || ''
|
|
||||||
form.value.examplefield = data.examplefield || ''
|
|
||||||
} catch (loadError) {
|
|
||||||
console.error('Error loading printedparts:', loadError)
|
|
||||||
error.value = 'Failed to load record'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitForm() {
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = { ...form.value }
|
||||||
assetnumber: form.value.assetnumber,
|
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
|
||||||
name: form.value.name || null,
|
delete payload.lowstockthreshold
|
||||||
serialnumber: form.value.serialnumber || null,
|
|
||||||
examplefield: form.value.examplefield || null
|
|
||||||
}
|
}
|
||||||
|
let printeditemid
|
||||||
let redirectId = itemId
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await printedpartsApi.update(itemId, payload)
|
await printedpartsApi.update(route.params.id, payload)
|
||||||
|
printeditemid = route.params.id
|
||||||
} else {
|
} else {
|
||||||
const response = await printedpartsApi.create(payload)
|
const response = await printedpartsApi.create(payload)
|
||||||
redirectId = response.data.data?.assetid
|
printeditemid = response.data.data.printeditemid
|
||||||
}
|
}
|
||||||
|
router.push(`/printedparts/${printeditemid}`)
|
||||||
router.push(redirectId ? `/printedparts/${redirectId}` : '/printedparts')
|
} catch (saveError) {
|
||||||
} catch (submitError) {
|
error.value = saveError.response?.data?.error?.message || 'Save failed'
|
||||||
console.error('Error saving printedparts:', submitError)
|
|
||||||
error.value = 'Failed to save record'
|
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
async function onImagePicked(event) {
|
||||||
if (isEdit.value) {
|
const file = event.target.files?.[0]
|
||||||
router.push(`/printedparts/${itemId}`)
|
if (!file) return
|
||||||
} else {
|
try {
|
||||||
router.push('/printedparts')
|
const response = await printedpartsApi.uploadImage(route.params.id, file)
|
||||||
|
imageurl.value = response.data.data.imageurl
|
||||||
|
} catch (uploadError) {
|
||||||
|
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage() {
|
||||||
|
try {
|
||||||
|
await printedpartsApi.deleteImage(route.params.id)
|
||||||
|
imageurl.value = null
|
||||||
|
} catch (deleteError) {
|
||||||
|
error.value = 'Could not remove the image'
|
||||||
|
console.error(deleteError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-card {
|
.image-row {
|
||||||
max-width: 800px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
.image-preview {
|
||||||
fieldset {
|
width: 6rem;
|
||||||
|
height: 6rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.35rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
legend {
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
margin-bottom: 0.375rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--danger);
|
|
||||||
color: white;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.form-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.form-hint { color: var(--text-light); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -64,3 +64,149 @@ def get_item(item_id: int):
|
|||||||
.limit(25).all())
|
.limit(25).all())
|
||||||
data['recenttransactions'] = [t.to_dict() for t in recent]
|
data['recenttransactions'] = [t.to_dict() for t in recent]
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
# --- catalog mutations (stage 6 adds permission gates on top of jwt) --------
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from shopdb.api import Setting
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
||||||
|
IMAGE_URL_PREFIX = '/api/printedparts/image/'
|
||||||
|
|
||||||
|
EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold',
|
||||||
|
'binlocation', 'printnotes')
|
||||||
|
|
||||||
|
|
||||||
|
def _imagedir():
|
||||||
|
return os.path.join(current_app.instance_path, 'printedpartsimages')
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_itemcode(item):
|
||||||
|
"""Set itemcode from the configured prefix + the flushed row id."""
|
||||||
|
prefix = Setting.get('printedparts_code_prefix') or '3DP'
|
||||||
|
item.itemcode = f'{prefix}-{item.printeditemid:04d}'
|
||||||
|
|
||||||
|
|
||||||
|
@printedparts_bp.route('/items', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def create_item():
|
||||||
|
"""Create a printed item; the itemcode is minted from the row id."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
itemname = (data.get('itemname') or '').strip()
|
||||||
|
if not itemname:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
|
||||||
|
|
||||||
|
threshold = data.get('lowstockthreshold')
|
||||||
|
if threshold is None:
|
||||||
|
threshold = int(Setting.get('printedparts_default_threshold') or 5)
|
||||||
|
|
||||||
|
item = PrintedItem(
|
||||||
|
itemname=itemname,
|
||||||
|
itemdescription=data.get('itemdescription'),
|
||||||
|
lowstockthreshold=threshold,
|
||||||
|
binlocation=data.get('binlocation'),
|
||||||
|
printnotes=data.get('printnotes'),
|
||||||
|
quantityonhand=0,
|
||||||
|
)
|
||||||
|
db.session.add(item)
|
||||||
|
db.session.flush() # assigns printeditemid
|
||||||
|
_mint_itemcode(item)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(item.to_dict(), message='Printed item created',
|
||||||
|
http_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||||
|
@jwt_required()
|
||||||
|
def update_item(item_id: int):
|
||||||
|
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
||||||
|
item = db.session.get(PrintedItem, item_id)
|
||||||
|
if not item:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Printed item {item_id} not found', http_code=404)
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if 'quantityonhand' in data:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'quantityonhand is ledger-managed; use restock or adjust')
|
||||||
|
for field in EDITABLE_FIELDS:
|
||||||
|
if field in data:
|
||||||
|
setattr(item, field, data[field])
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(item.to_dict(), message='Printed item updated')
|
||||||
|
|
||||||
|
|
||||||
|
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_item(item_id: int):
|
||||||
|
"""Soft-retire an item; its ledger history stays."""
|
||||||
|
item = db.session.get(PrintedItem, item_id)
|
||||||
|
if not item:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Printed item {item_id} not found', http_code=404)
|
||||||
|
item.isactive = False
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(message='Printed item retired')
|
||||||
|
|
||||||
|
|
||||||
|
# --- item image: the models.py upload/serve/delete trio ---------------------
|
||||||
|
|
||||||
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def upload_item_image(item_id: int):
|
||||||
|
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
||||||
|
item = db.session.get(PrintedItem, item_id)
|
||||||
|
if not item:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Printed item {item_id} not found', http_code=404)
|
||||||
|
upload = request.files.get('file')
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||||
|
ext = os.path.splitext(upload.filename)[1].lower()
|
||||||
|
if ext not in IMAGE_EXTENSIONS:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
f'Unsupported image type {ext}')
|
||||||
|
|
||||||
|
imagedir = _imagedir()
|
||||||
|
os.makedirs(imagedir, exist_ok=True)
|
||||||
|
for old in glob.glob(os.path.join(
|
||||||
|
imagedir, secure_filename(f'printeditem-{item_id}') + '.*')):
|
||||||
|
os.remove(old)
|
||||||
|
|
||||||
|
filename = secure_filename(f'printeditem-{item_id}{ext}')
|
||||||
|
upload.save(os.path.join(imagedir, filename))
|
||||||
|
item.imageurl = f'{IMAGE_URL_PREFIX}{filename}'
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(item.to_dict(), message='Item image uploaded')
|
||||||
|
|
||||||
|
|
||||||
|
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
|
def serve_item_image(filename):
|
||||||
|
"""Serve an uploaded item image (public - kiosk and list read it)."""
|
||||||
|
from flask import send_from_directory
|
||||||
|
return send_from_directory(_imagedir(), filename)
|
||||||
|
|
||||||
|
|
||||||
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
def delete_item_image(item_id: int):
|
||||||
|
"""Clear an item image; delete the file only if this plugin owns it."""
|
||||||
|
item = db.session.get(PrintedItem, item_id)
|
||||||
|
if not item:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Printed item {item_id} not found', http_code=404)
|
||||||
|
url = item.imageurl or ''
|
||||||
|
if url.startswith(IMAGE_URL_PREFIX):
|
||||||
|
filename = secure_filename(url[len(IMAGE_URL_PREFIX):])
|
||||||
|
path = os.path.join(_imagedir(), filename)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
item.imageurl = None
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(item.to_dict(), message='Item image removed')
|
||||||
|
|||||||
Reference in New Issue
Block a user