send_webhook(title,text) posts alerts to an optional webhook (Teams Incoming Webhook / Workflow, or generic JSON) via alert_webhook_url + alert_webhook_format settings; send_alert fans out to it alongside email; exposed on shopdb.api (0.13.0->0.14.0, PLUGIN-HOOKS synced); low-stock posts on its custom-recipient path too. Also: recent-transactions table shows the consumed print-file revision.
378 lines
13 KiB
Vue
378 lines
13 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="item">
|
|
<div class="hero-card">
|
|
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
|
|
:alt="item.itemname" class="hero-image" />
|
|
<div class="hero-content">
|
|
<h2 class="hero-title">{{ item.itemname }}</h2>
|
|
<div class="hero-meta">
|
|
<span class="badge badge-secondary">{{ item.itemcode }}</span>
|
|
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
|
{{ item.quantityonhand }} on hand
|
|
</span>
|
|
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
|
|
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
|
</div>
|
|
<div class="hero-details">
|
|
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
|
</div>
|
|
<div class="hero-actions">
|
|
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
|
|
Restock
|
|
</button>
|
|
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
|
|
Adjust
|
|
</button>
|
|
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
|
class="btn btn-secondary btn-sm">Edit</router-link>
|
|
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
|
|
class="btn btn-secondary btn-sm">Part Label</router-link>
|
|
<button v-if="item.isactive" class="btn btn-danger btn-sm"
|
|
@click="retireItem">Retire</button>
|
|
<button v-else class="btn btn-primary btn-sm"
|
|
@click="restoreItem">Restore</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-grid">
|
|
<div class="content-column">
|
|
<div class="section-card">
|
|
<h3 class="section-title">Details</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Item code</span>
|
|
<span class="info-value">{{ item.itemcode }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="item.gagelabtag">
|
|
<span class="info-label">Gage lab tag</span>
|
|
<span class="info-value">{{ item.gagelabtag }}</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">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>
|
|
<ul class="file-revision-list">
|
|
<li v-for="revision in files" :key="revision.fileid"
|
|
:class="{ 'current-revision': revision === files[0] }">
|
|
<div class="file-main">
|
|
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)"
|
|
class="file-name">
|
|
{{ revision.filename }}
|
|
</a>
|
|
<span class="badge badge-secondary">rev {{ revision.revision }}</span>
|
|
<span v-if="revision === files[0]" class="badge badge-success">current</span>
|
|
</div>
|
|
<div class="file-meta">
|
|
{{ formatSize(revision.filesize) }} -
|
|
{{ revision.uploadedby }} -
|
|
{{ formatDate(revision.uploadeddate) }}
|
|
<span v-if="revision.uploadnote"> - {{ revision.uploadnote }}</span>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm file-delete"
|
|
@click="removeRevision(revision)">Delete</button>
|
|
</li>
|
|
<li v-if="files.length === 0" class="empty-state">
|
|
No print file uploaded yet
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<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>Rev</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.revision != null ? transaction.revision : '-' }}</td>
|
|
<td>{{ transaction.employeename || transaction.employeesso }}</td>
|
|
<td>{{ transaction.reason || '-' }}</td>
|
|
</tr>
|
|
<tr v-if="!item.recenttransactions?.length">
|
|
<td colspan="6" class="empty-state">No transactions yet</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="audit-footer">
|
|
Created {{ formatDate(item.createddate) }} -
|
|
Modified {{ formatDate(item.modifieddate) }}
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">Item not found</div>
|
|
|
|
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
|
|
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
|
|
<div class="form-group">
|
|
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
|
|
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
|
|
</div>
|
|
<div v-if="ledgerMode === 'adjust'" class="form-group">
|
|
<label>Reason *</label>
|
|
<input v-model="ledgerReason" type="text" class="form-control"
|
|
placeholder="e.g., damaged parts scrapped, recount" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Your badge / SSO *</label>
|
|
<input v-model="ledgerBadge" type="text" class="form-control"
|
|
placeholder="Scan badge or type SSO" />
|
|
</div>
|
|
<template #footer>
|
|
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
|
|
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
|
|
</button>
|
|
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
|
|
</template>
|
|
</Modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { printedpartsApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import Modal from '@/components/Modal.vue'
|
|
|
|
const route = useRoute()
|
|
const item = ref(null)
|
|
const loading = ref(true)
|
|
|
|
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 {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
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)
|
|
const ledgerReason = ref('')
|
|
const ledgerBadge = ref('')
|
|
const ledgerSaving = ref(false)
|
|
const ledgerError = ref('')
|
|
|
|
function openLedger(mode) {
|
|
ledgerMode.value = mode
|
|
ledgerQuantity.value = null
|
|
ledgerReason.value = ''
|
|
ledgerBadge.value = ''
|
|
ledgerError.value = ''
|
|
ledgerOpen.value = true
|
|
}
|
|
|
|
async function submitLedger() {
|
|
ledgerSaving.value = true
|
|
ledgerError.value = ''
|
|
try {
|
|
if (ledgerMode.value === 'restock') {
|
|
await printedpartsApi.restock(item.value.printeditemid, {
|
|
quantity: ledgerQuantity.value, badge: ledgerBadge.value
|
|
})
|
|
} else {
|
|
await printedpartsApi.adjust(item.value.printeditemid, {
|
|
quantitychange: ledgerQuantity.value,
|
|
reason: ledgerReason.value,
|
|
badge: ledgerBadge.value
|
|
})
|
|
}
|
|
ledgerOpen.value = false
|
|
const response = await printedpartsApi.get(item.value.printeditemid)
|
|
item.value = response.data.data
|
|
} catch (submitError) {
|
|
ledgerError.value =
|
|
submitError.response?.data?.data?.error?.message ||
|
|
submitError.response?.data?.error?.message || 'Submit failed'
|
|
} finally {
|
|
ledgerSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function retireItem() {
|
|
if (!window.confirm(
|
|
`Retire ${item.value.itemname}? It leaves the storefront and kiosk; `
|
|
+ 'history and the bin label stay, and it can be restored later.')) return
|
|
try {
|
|
await printedpartsApi.remove(item.value.printeditemid)
|
|
const response = await printedpartsApi.get(item.value.printeditemid)
|
|
item.value = response.data.data
|
|
} catch (retireError) {
|
|
console.error('Retire failed:', retireError)
|
|
}
|
|
}
|
|
|
|
async function restoreItem() {
|
|
try {
|
|
const response = await printedpartsApi.restore(item.value.printeditemid)
|
|
item.value = response.data.data
|
|
} catch (restoreError) {
|
|
console.error('Restore failed:', restoreError)
|
|
}
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-'
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<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;
|
|
}
|
|
.file-revision-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
.file-revision-list li {
|
|
position: relative;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.45rem;
|
|
padding: 0.6rem 5.5rem 0.6rem 0.8rem;
|
|
}
|
|
.file-revision-list li.current-revision { border-color: var(--primary); }
|
|
.file-main {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.file-name {
|
|
font-weight: 600;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.file-meta {
|
|
color: var(--text-light);
|
|
font-size: 0.85rem;
|
|
margin-top: 0.2rem;
|
|
}
|
|
.file-delete {
|
|
position: absolute;
|
|
top: 0.55rem;
|
|
right: 0.6rem;
|
|
}
|
|
.qty-in { color: var(--success); }
|
|
</style>
|