Merge remote-tracking branch 'origin/main' into wilm

This commit is contained in:
benjamin.baker
2026-08-24 14:52:59 -04:00
66 changed files with 5026 additions and 391 deletions

View File

@@ -1763,3 +1763,31 @@ td.actions .btn + .btn {
independently. */
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }
/* --- Printing from inside the app shell -------------------------------------
*
* Core's label pages are full-screen routes precisely so the shell cannot reach
* the stock. A tool someone works in for ten minutes wants the navigation
* though, so the shell is hidden here instead: the sidebar goes, the content
* column gives back the margin it was holding for it, and the notification
* banner - which is chrome, not content - goes with them.
*
* Anything the page itself must keep off the paper marks itself .no-print. */
@media print {
.sidebar,
.notification-banner,
.no-print {
display: none !important;
}
.app-layout {
display: block !important;
min-height: 0 !important;
}
.main-content {
margin-left: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
}

View File

@@ -97,11 +97,15 @@ const blueprintUrl = computed(() => {
// the wrong level's dimensions is precisely how a marker ends up plausibly
// placed and wrong.
const markerX = computed(() => {
return (props.left / dimensionsFor(props.levelid).width) * 100
const fraction = props.left / dimensionsFor(props.levelid).width
const { offsetX, scaleX } = paintedBox.value
return (offsetX + fraction * scaleX) * 100
})
const markerY = computed(() => {
return (props.top / dimensionsFor(props.levelid).height) * 100
const fraction = props.top / dimensionsFor(props.levelid).height
const { offsetY, scaleY } = paintedBox.value
return (offsetY + fraction * scaleY) * 100
})
// Marker style with counter-scale to maintain constant size
@@ -111,6 +115,42 @@ const markerStyle = computed(() => ({
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
}))
// The frame the marker's percentages are measured against MUST be the frame the
// drawing is painted in. The image used object-fit: contain inside a fixed
// 390x300 box, so a level whose drawing is not that aspect ratio was letterboxed
// or pillarboxed - and the marker, positioned at a percentage of the BOX, then
// sat in a different coordinate space from the drawing inside it. A tall
// drawing pillarboxes, every marker's x shifts, and one near the left edge
// lands in the grey gutter beside the map. Levels near 390:300 looked correct,
// which is why it only affected some assets.
//
// The preview is a fixed box and object-fit: contain paints the drawing inside it
// at the drawing's own aspect ratio, so the drawing does not fill the box: it is
// letterboxed on one axis. A marker placed at a percentage of the BOX therefore
// lands somewhere else than the same percentage of the DRAWING, on any level
// whose shape is not the box's.
//
// So convert: work out where the painted drawing sits inside the box, then map
// the marker into box coordinates. Doing it here rather than by resizing the
// frame keeps the centring transform below honest - its percentages resolve
// against the element, so the element has to stay the size of the box.
const PREVIEW_WIDTH = 390 // keep in step with .map-preview in the styles
const PREVIEW_HEIGHT = 300
const PREVIEW_ASPECT = PREVIEW_WIDTH / PREVIEW_HEIGHT
// Fraction of the box the drawing covers, and the gap before it, per axis.
const paintedBox = computed(() => {
const { width, height } = dimensionsFor(props.levelid)
const levelAspect = width / height
if (levelAspect >= PREVIEW_ASPECT) {
// Wider than the box: full width, bars above and below.
const scale = PREVIEW_ASPECT / levelAspect
return { scaleX: 1, offsetX: 0, scaleY: scale, offsetY: (1 - scale) / 2 }
}
// Taller than the box: full height, bars left and right.
const scale = levelAspect / PREVIEW_ASPECT
return { scaleX: scale, offsetX: (1 - scale) / 2, scaleY: 1, offsetY: 0 }
})
// Transform style that centers on the marker and zooms toward it
const transformStyle = computed(() => {
// Calculate translation to center the marker in the preview
@@ -263,6 +303,9 @@ watch(currentTheme, () => {
.map-transform {
position: relative;
/* Fills the preview. The centring transform translates by percentages, which
resolve against THIS element, so it must stay the size of the box it is
centring within. The letterbox is accounted for in the marker maths. */
width: 100%;
height: 100%;
transition: transform 0.15s ease-out;
@@ -271,7 +314,10 @@ watch(currentTheme, () => {
.map-image {
width: 100%;
height: 100%;
/* contain, so a level of any shape is shown whole and undistorted. Where the
bars fall is computed in paintedBox and folded into the marker position. */
object-fit: contain;
display: block;
}
.marker-dot {

View File

@@ -0,0 +1,42 @@
<template>
<!-- Renders nothing at all when there is no suggestion, so a list can drop
this in beside its empty state without an extra v-if of its own. -->
<p v-if="suggestion" class="search-suggestion">
Did you mean
<button type="button" class="linklike" @click="$emit('pick', suggestion)">
{{ suggestion }}
</button>?
</p>
</template>
<script setup>
defineProps({
suggestion: { type: String, default: null },
})
defineEmits(['pick'])
</script>
<style scoped>
.search-suggestion {
margin: 0.5rem 0 0;
color: var(--text-light, #666);
font-size: 0.9rem;
}
/* A button, not an anchor: it re-runs the search in place rather than
navigating, and a link that does not link is a lie to anyone using a
keyboard or a screen reader. */
.linklike {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--primary, #0066cc);
cursor: pointer;
text-decoration: underline;
}
.linklike:hover {
text-decoration: none;
}
</style>

View File

@@ -1,5 +1,6 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSearchSuggestion } from './searchSuggestion'
// Keep a list page's current page and search term in the URL query so the
// browser Back button restores them. Without this a list keeps page in local
@@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router'
// deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object.
//
// SPELLING SUGGESTIONS come along for free, because this composable already
// owns the search term. A list reports how many rows it got and gets back a
// suggestion to offer when that number is zero:
//
// const { search, suggestion, reportCount } = useListQuery({ onChange: load })
// // at the end of load(): reportCount(rows.value.length)
// // in the empty state: <SearchSuggestion :suggestion="suggestion"
// // @pick="applySuggestion" />
//
// A list that never calls reportCount simply never offers one, which is what
// every list did before.
export function useListQuery(options = {}) {
const route = useRoute()
const router = useRouter()
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
if (changed) onChange()
})
return { page, search, extras, setPage, setSearch, setExtra }
const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
// Called by the list after a fetch. Zero rows for a non-empty search is the
// only moment a suggestion is worth asking for.
function reportCount(count) {
if (count > 0 || !search.value) clearSuggestion()
else checkSuggestion(search.value)
}
return {
page, search, extras, setPage, setSearch, setExtra,
suggestion, reportCount,
}
}

View File

@@ -0,0 +1,56 @@
import { ref } from 'vue'
import api from '../api'
/**
* "Did you mean" for a search that found nothing.
*
* ONE composable for every list in the product. Thirty routes take a search
* parameter across a dozen files, so carrying a suggestion in each of their
* responses would be a large change now and a thing every future plugin has to
* remember. Instead a page that renders no rows asks for a spelling, and a page
* that has not adopted this shows nothing - exactly what it showed before.
*
* Usage in a list view:
*
* const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
* // after a fetch:
* items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value)
*
* and in the template, beside the empty state:
*
* <SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
*/
export function useSearchSuggestion() {
const suggestion = ref(null)
// Guards a slow answer arriving after a newer search has already been typed,
// which would offer a spelling for a query nobody is looking at any more.
let latest = 0
function clearSuggestion() {
suggestion.value = null
latest += 1
}
async function checkSuggestion(query) {
const term = (query || '').trim()
if (term.length < 2) {
clearSuggestion()
return
}
const ticket = ++latest
try {
const response = await api.get('/search/suggest', { params: { q: term } })
if (ticket !== latest) return
const offered = response.data?.data?.suggestion || null
// Never offer back what was typed: a suggestion identical to the query
// reads as a broken feature.
suggestion.value = offered && offered !== term.toLowerCase() ? offered : null
} catch (err) {
// A search that found nothing is already the answer. Failing to improve
// on it is not worth an error in front of anyone.
if (ticket === latest) suggestion.value = null
}
}
return { suggestion, checkSuggestion, clearSuggestion }
}

View File

@@ -0,0 +1,35 @@
/**
* Securesend plugin routes.
*
* Auto-discovered by the router via import.meta.glob, so no registration
* edit is needed. Every route carries meta.plugin 'securesend' so the ADR-009
* guard redirects to the dashboard when the securesend backend plugin is
* disabled. Form routes add requiresAuth so anonymous users cannot reach
* create or edit.
*/
export default [
{
path: 'securesend',
name: 'securesend',
component: () => import('../../views/securesend/SecuresendList.vue'),
meta: { plugin: 'securesend' }
},
{
path: 'securesend/new',
name: 'securesend-new',
component: () => import('../../views/securesend/SecuresendForm.vue'),
meta: { requiresAuth: true, plugin: 'securesend' }
},
{
path: 'securesend/:id',
name: 'securesend-detail',
component: () => import('../../views/securesend/SecuresendDetail.vue'),
meta: { plugin: 'securesend' }
},
{
path: 'securesend/:id/edit',
name: 'securesend-edit',
component: () => import('../../views/securesend/SecuresendForm.vue'),
meta: { requiresAuth: true, plugin: 'securesend' }
}
]

View File

@@ -24,15 +24,19 @@ import JsBarcode from 'jsbarcode'
import { getQrLogo } from '@/utils/siteSettings'
// Label stock actually loaded in the printers, with the code size, padding and
// quiet zone that fit each one. Sizes are inches, because that is what the
// stock is sold as and what @page takes.
// Label stock actually loaded in the printers, with the code size, padding,
// quiet zone and code-to-caption gap that fit each one. Sizes are inches,
// because that is what the stock is sold as and what @page takes.
//
// quiet and gap are NOT the same measurement. quiet is the clear space a
// scanner needs around the code and is fixed by the symbology; gap is only how
// far the caption sits from it, and is a matter of taste per stock.
export const LABEL_PRESETS = [
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, gap: 0.03, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, gap: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, gap: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, gap: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, gap: 0.1, labelfont: 12 },
]
// CODE128 is what the scanners on the floor are configured for, and a label

View File

@@ -42,6 +42,7 @@
<div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}"
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</div>
<div v-else class="results-list">
@@ -94,12 +95,14 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
import SearchSuggestion from '../components/SearchSuggestion.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
const suggestion = ref(null)
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
async function search(q) {
if (!q || q.length < 2) {
results.value = []
suggestion.value = null
return
}
@@ -184,6 +188,10 @@ async function search(q) {
try {
const response = await searchApi.search(q)
const data = response.data.data
// The global endpoint carries the suggestion with the results, so no second
// call is needed here. A list page that has no such key uses the
// useSearchSuggestion composable against /search/suggest instead.
suggestion.value = data?.suggestion || null
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -212,6 +220,14 @@ async function search(q) {
}
}
// Take the offered spelling: put it in the box and search it, so the person
// sees what was searched rather than results for a word they did not type.
function applySuggestion(word) {
searchInput.value = word
suggestion.value = null
performSearch()
}
function performSearch() {
const term = searchInput.value.trim()
if (!term) return

View File

@@ -0,0 +1,149 @@
<template>
<div class="detail-page" v-if="item">
<div class="hero-card">
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Securesend' }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/securesend/${itemId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-details">
<div class="detail-item" v-if="item.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ item.assetnumber }}</span>
</div>
<div class="detail-item" v-if="item.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ item.serialnumber }}</span>
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Securesend Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Example Field</span>
<span class="info-value">{{ item.examplefield || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<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 class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/securesend/${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="/securesend" class="btn btn-secondary">Back to Securesend</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
// local api client. move into src/api/index.js (see
// plugins/securesend/frontend-api-snippet.js) then swap for:
// import { securesendApi } from '../../api'
const securesendApi = {
list(params = {}) { return api.get('/securesend', { params }) },
get(itemId) { return api.get(`/securesend/${itemId}`) },
create(data) { return api.post('/securesend', data) },
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const itemId = route.params.id
const item = ref(null)
const loading = ref(true)
onMounted(loadItem)
async function loadItem() {
loading.value = true
try {
const response = await securesendApi.get(itemId)
item.value = response.data.data
} catch (error) {
console.error('Error loading securesend:', error)
item.value = null
} finally {
loading.value = false
}
}
async function confirmDelete() {
if (confirm('Delete this record?')) {
try {
await securesendApi.remove(itemId)
router.push('/securesend')
} catch (error) {
console.error('Error deleting securesend:', error)
}
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.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>

View File

@@ -0,0 +1,228 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Securesend' : 'Add Securesend' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
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 class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<fieldset>
<legend>Securesend 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 v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
// local api client. move into src/api/index.js (see
// plugins/securesend/frontend-api-snippet.js) then swap for:
// import { securesendApi } from '../../api'
const securesendApi = {
list(params = {}) { return api.get('/securesend', { params }) },
get(itemId) { return api.get(`/securesend/${itemId}`) },
create(data) { return api.post('/securesend', data) },
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const itemId = route.params.id
const isEdit = computed(() => !!itemId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
examplefield: ''
})
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (isEdit.value) {
await loadItem()
}
})
async function loadItem() {
try {
const response = await securesendApi.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 securesend:', loadError)
error.value = 'Failed to load record'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
examplefield: form.value.examplefield || null
}
let redirectId = itemId
if (isEdit.value) {
await securesendApi.update(itemId, payload)
} else {
const response = await securesendApi.create(payload)
redirectId = response.data.data?.assetid
}
router.push(redirectId ? `/securesend/${redirectId}` : '/securesend')
} catch (submitError) {
console.error('Error saving securesend:', submitError)
error.value = 'Failed to save record'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/securesend/${itemId}`)
} else {
router.push('/securesend')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
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;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Securesend</h2>
<router-link to="/securesend/new" class="btn btn-primary">Add Securesend</router-link>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Example Field</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.assetid">
<td>{{ item.assetnumber || '-' }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.examplefield || '-' }}</td>
<td class="actions">
<router-link
:to="`/securesend/${item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No securesend records found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/securesend/frontend-api-snippet.js) then swap for:
// import { securesendApi } from '../../api'
const securesendApi = {
list(params = {}) { return api.get('/securesend', { params }) },
get(itemId) { return api.get(`/securesend/${itemId}`) },
create(data) { return api.post('/securesend', data) },
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
}
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
const response = await securesendApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading securesend:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadItems()
}, 300)
}
function goToPage(target) {
if (target >= 1 && target <= totalPages.value) {
page.value = target
loadItems()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadItems()
}
</script>
<style scoped>
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
</style>

View File

@@ -55,6 +55,7 @@
<tr v-if="vendors.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No vendors found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadVendors()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -232,6 +243,7 @@ async function loadVendors() {
const response = await vendorsApi.list(params)
vendors.value = response.data.data || []
reportCount(vendors.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading vendors:', err)