Merge remote-tracking branch 'origin/main' into wilm
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
42
frontend/src/components/SearchSuggestion.vue
Normal file
42
frontend/src/components/SearchSuggestion.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
56
frontend/src/composables/searchSuggestion.js
Normal file
56
frontend/src/composables/searchSuggestion.js
Normal 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 }
|
||||
}
|
||||
35
frontend/src/router/routes/securesend.js
Normal file
35
frontend/src/router/routes/securesend.js
Normal 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' }
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
149
frontend/src/views/securesend/SecuresendDetail.vue
Normal file
149
frontend/src/views/securesend/SecuresendDetail.vue
Normal 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>
|
||||
228
frontend/src/views/securesend/SecuresendForm.vue
Normal file
228
frontend/src/views/securesend/SecuresendForm.vue
Normal 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>
|
||||
142
frontend/src/views/securesend/SecuresendList.vue
Normal file
142
frontend/src/views/securesend/SecuresendList.vue
Normal 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>
|
||||
14
frontend/src/views/vendors/VendorsList.vue
vendored
14
frontend/src/views/vendors/VendorsList.vue
vendored
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user