diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd0963d..35ef7308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,37 @@ ADR-007 and ADR-002. - **Contents can be positioned inside the label without moving the label.** Align and Vertical place the code and caption block anywhere on the stock, and Content X/Y nudge it from there. The existing X/Y nudge kept its - behaviour and is now labelled Media X/Y: it moves the whole printed area, for + behavior and is now labeled Media X/Y: it moves the whole printed area, for a printer whose origin is off, which is a different problem from composing a label. +- **A picture can go on the label.** Pick an image once and it prints on every + label - above the code, beside it, or behind everything as a watermark - on + the front, the back, or both. The file never leaves the browser, so an + air-gapped site can use it, and it is deliberately not saved between visits: + a data URL runs to megabytes and would take every sizing value with it when + the storage quota gave out. +- **The back can carry its own code, in its own symbology.** "Back carries" now + defaults to a code rather than text, takes per-row content from the CSV + `back` column, and has its own QR/CODE128 choice - a QR on the front that + opens the record, a CODE128 on the back that the bench scanner already reads. + A back set to carry a code can no longer be laid out as text-only: that + combination printed a blank card that looked like the code had failed, and it + is corrected on load for anyone who already saved it. + +### Fixed + +- **The generator prints from inside the app shell.** It was a full-screen + route with no sidebar and a narrow column of controls on the left. It is now + an ordinary page - navigation, full width - and `style.css` hides the shell at + print time, so the stock still gets nothing but labels. Verified against a + real print PDF rather than a preview. +- A test print paired each page with a code by loop position while the codes + are keyed to position in the whole run, so a two-sided test card could carry + the wrong code. The page index now travels with the page. +- The back label's dotted preview outline outspecified the print reset and drew + a dashed rule down the edge of every second card. + ### Changed - CSV parsing and page building moved out of the generator component into diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 36e9d98c..5038c546 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -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; + } +} diff --git a/frontend/src/components/SearchSuggestion.vue b/frontend/src/components/SearchSuggestion.vue new file mode 100644 index 00000000..0e8c9e90 --- /dev/null +++ b/frontend/src/components/SearchSuggestion.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js index cee61558..205dba43 100644 --- a/frontend/src/composables/listQuery.js +++ b/frontend/src/composables/listQuery.js @@ -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: +// +// 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, + } } diff --git a/frontend/src/composables/searchSuggestion.js b/frontend/src/composables/searchSuggestion.js new file mode 100644 index 00000000..9242c9a7 --- /dev/null +++ b/frontend/src/composables/searchSuggestion.js @@ -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: + * + * + */ +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 } +} diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue index 48d35d5d..80d262f8 100644 --- a/frontend/src/views/SearchResults.vue +++ b/frontend/src/views/SearchResults.vue @@ -42,6 +42,7 @@
No results found for "{{ query }}" +
@@ -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 diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue index 734d5516..1b08da3e 100644 --- a/frontend/src/views/vendors/VendorsList.vue +++ b/frontend/src/views/vendors/VendorsList.vue @@ -55,6 +55,7 @@ No vendors found + @@ -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) diff --git a/plugins/computers/frontend/views/PCsList.vue b/plugins/computers/frontend/views/PCsList.vue index d5d8567d..361c2c20 100644 --- a/plugins/computers/frontend/views/PCsList.vue +++ b/plugins/computers/frontend/views/PCsList.vue @@ -66,6 +66,7 @@ No computers found + @@ -91,10 +92,20 @@ import { computersApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { colorStyle } from '@/utils/colorStyle' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const computers = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers }) + +// 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) + loadComputers() +} const totalPages = ref(1) const perPage = ref(20) @@ -115,6 +126,7 @@ async function loadComputers() { const response = await computersApi.list(params) computers.value = response.data.data || [] + reportCount(computers.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading computers:', error) diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue index 01c048ca..dcbbfc6f 100644 --- a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue +++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue @@ -88,6 +88,7 @@ No articles found + @@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue' import { knowledgebaseApi, applicationsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const loading = ref(true) const articles = ref([]) const topics = ref([]) const stats = ref(null) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles }) + +// 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) + loadArticles() +} const perPage = ref(20) const totalPages = ref(1) const topicFilter = ref('') @@ -161,6 +172,7 @@ async function loadArticles() { const response = await knowledgebaseApi.list(params) articles.value = response.data.data || [] + reportCount(articles.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading articles:', error) diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue index 19732aab..158d45a8 100644 --- a/plugins/machines/frontend/views/MachinesList.vue +++ b/plugins/machines/frontend/views/MachinesList.vue @@ -79,6 +79,7 @@ No machines found + @@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle" import { machinesApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const machines = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadMachines() +} const totalPages = ref(1) const perPage = ref(20) @@ -129,6 +142,7 @@ async function loadMachines() { const response = await machinesApi.list(params) machines.value = response.data.data || [] + reportCount(machines.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading machines:', error) diff --git a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue index f0ea431b..856c8fc1 100644 --- a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue +++ b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue @@ -73,6 +73,7 @@ No measuring tools found + @@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle' import { measuringtoolsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const tools = ref([]) const types = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools }) + +// 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) + loadTools() +} const typeid = ref('') const calibrationstatus = ref('') const totalPages = ref(1) @@ -133,6 +144,7 @@ async function loadTools() { const response = await measuringtoolsApi.list(params) tools.value = response.data.data || [] + reportCount(tools.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (err) { console.error('Error loading measuring tools:', err) diff --git a/plugins/network/frontend/views/NetworkDevicesList.vue b/plugins/network/frontend/views/NetworkDevicesList.vue index 8195a9af..e6e5719f 100644 --- a/plugins/network/frontend/views/NetworkDevicesList.vue +++ b/plugins/network/frontend/views/NetworkDevicesList.vue @@ -97,6 +97,7 @@ No network devices found + @@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle" import { networkApi, vendorsApi, locationsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const devices = ref([]) const deviceTypes = ref([]) const vendors = ref([]) const locations = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices }) + +// 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) + loadDevices() +} const selectedType = ref(null) const vendorFilter = ref('') const locationFilter = ref('') @@ -220,6 +231,7 @@ async function loadDevices() { const response = await networkApi.list(params) devices.value = response.data.data || [] + reportCount(devices.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading network devices:', error) diff --git a/plugins/printers/frontend/views/PrintersList.vue b/plugins/printers/frontend/views/PrintersList.vue index 36cacdf7..adc92675 100644 --- a/plugins/printers/frontend/views/PrintersList.vue +++ b/plugins/printers/frontend/views/PrintersList.vue @@ -68,6 +68,7 @@ No printers found + @@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue' import { printersApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const printers = ref([]) const printerTypes = ref([]) const typeFilter = ref('') const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadPrinters() +} const totalPages = ref(1) const perPage = ref(20) @@ -125,6 +138,7 @@ async function loadPrinters() { const response = await printersApi.list(params) printers.value = response.data.data || [] + reportCount(printers.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading printers:', error) diff --git a/plugins/tools/frontend/routes.js b/plugins/tools/frontend/routes.js index aa152858..e17ac47e 100644 --- a/plugins/tools/frontend/routes.js +++ b/plugins/tools/frontend/routes.js @@ -3,9 +3,12 @@ * * `default` = AppLayout child routes; `toplevel` = full-screen routes. * - * The index sits inside the app shell. Each tool that PRINTS is toplevel, the - * way every other label page in this repo is: printing from inside AppLayout - * would put the sidebar and header on the label stock. + * The generator sits INSIDE the app shell, unlike the label pages in core. + * Those are toplevel because a print from inside AppLayout would put the + * sidebar on the label stock - which is true unless the print stylesheet says + * otherwise, and style.css now does: the shell is hidden at print time so only + * the labels reach the page. A tool someone spends ten minutes in wants the + * navigation; a one-shot print page does not. */ export default [ { @@ -14,11 +17,8 @@ export default [ component: () => import('./views/ToolsIndex.vue'), meta: { requiresAuth: true, plugin: 'tools' } }, -] - -export const toplevel = [ { - path: '/tools/codes', + path: 'tools/codes', name: 'tools-codes', component: () => import('./views/CodeGenerator.vue'), meta: { requiresAuth: true, plugin: 'tools' } diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 9b1cac6c..78fb6a60 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -1,12 +1,12 @@