This commit is contained in:
j3d1 2026-08-23 00:56:03 +02:00
parent 5c3b7fc252
commit bbe52e4a78
9 changed files with 817 additions and 287 deletions

View file

@ -43,6 +43,9 @@
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
@ -77,6 +80,10 @@
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
@ -138,6 +145,17 @@ export default {
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
},
// Routes to Print.vue with this item's own raw identity - userHandle + id, the same shape
// InventoryDetail.vue's own Print label button sends - rather than any pre-built link, so
// the print page can derive every item template (item-handle, owner-handle, item-url,
// the short link, ) itself and isn't tied to whichever one this button "suggests".
// Group-owned items have no individual owner handle - short-id.js's group_item kind
// resolves them via owner_group instead (see shortIdLink above) - so there's no
// {userHandle, id} to build here yet; they get no print link until that's supported too.
printLinkFor(item) {
if (!item.owner) return null
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}}
},
},
async mounted() {
await this.fetchInventoryItems()

View file

@ -47,7 +47,7 @@
Delete
</button>
<button class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'item-url', user, id}})">
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: user, id}})">
<b-icon-printer></b-icon-printer>
Print label
</button>

View file

@ -102,21 +102,6 @@
</div>
</div>
<div class="mb-3">
<label class="form-label">Code type</label>
<div class="btn-group d-block" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id + '-fallback'"
autocomplete="off" :value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary"
:for="'code-type-' + opt.id + '-fallback'">
{{ opt.label }}
</label>
</template>
</div>
</div>
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
<b-icon-download class="me-1"></b-icon-download>
Download label as PNG
@ -207,19 +192,6 @@
</label>
</div>
</div>
<div class="col-auto">
<label class="form-label">Code type</label>
<div class="btn-group" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id" autocomplete="off"
:value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary" :for="'code-type-' + opt.id">
{{ opt.label }}
</label>
</template>
</div>
</div>
<div class="col-auto">
<label class="form-label">Copies</label>
<input type="number" class="form-control copies-input"
@ -275,7 +247,7 @@
<div class="row">
<div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate" :code-type="codeType"
<label-layout-preview :fields="fields" :value="selectedTemplate"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
@ -287,12 +259,27 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder, QR_CODE_TYPES} from "@/label.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
// The extra "Calculated" fields (and label-layouts.js "Short link (QR code)" template input) this
// view adds on top of label-layouts.js's own DERIVED_VARS - resolving either needs the current
// identityIdByHandle map (see store.js's fetchIdMap) to turn a handle into the numeric
// owner_identity_id short-id.js's 'item'/'storage_location' kinds encode, so neither can be a pure
// fields->value calc like the others and both live here instead of in label-layouts.js. Excluded
// from baseVars below since, unlike every other entry KNOWN_VARS picks up from a template's
// required_vars, neither is ever typed directly.
const SHORT_URL_VAR = "shortUrl";
// The bare short-id.js token itself (e.g. "~AbCd12"), with no domain or leading "/" - what
// shortUrl's own path is built from (see fields()/the shortId method below), for a label that
// wants just the compact code rather than a full scannable URL.
const SHORT_ID_VAR = "shortId";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
@ -314,15 +301,6 @@ const RULER_TIERS = [
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];
// Display labels for label.js's QR_CODE_TYPES - the code-type button group below reads this
// (via codeTypeOptions) rather than hard-coding its own list, so a new symbology added to
// anyd-qr.js/QR_CODE_TYPES only needs a label here, not a whole new radio group.
const CODE_TYPE_LABELS = {
"qr": "QR",
"micro-qr": "Micro QR",
"rmqr": "rMQR",
};
export default {
name: "Print",
components: {
@ -386,10 +364,6 @@ export default {
// instead, so it reads across the tape rather than along it. See label.js's
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
orientation: "along",
// Which of label.js's QR_CODE_TYPES every "qrcode" leaf in the current template
// renders as - one global choice for the whole label, same as `orientation` above,
// since a template only ever has one scannable code in practice.
codeType: "qr",
fallbackReady: false,
// TODO: replace with the real commands for our printers.
@ -417,19 +391,20 @@ export default {
};
},
computed: {
...mapGetters(["identityIdByHandle"]),
// The base variables the "Label content" form renders an input for, and the derived ones
// it instead calculates and lists read-only beside that form - plain passthroughs, but
// keep the template from importing label-layouts.js just for these.
// keep the template from importing label-layouts.js just for these. SHORT_URL_VAR is
// excluded here even though the "Short link (QR code)" template's required_vars puts it in
// BASE_VARS (it isn't a label-layouts.js DERIVED_VARS entry) - see fields() below for why
// it's calculated, not typed. SHORT_ID_VAR isn't referenced by any template's
// required_vars (so isn't actually in BASE_VARS today), filtered out too in case one ever
// is.
baseVars() {
return BASE_VARS;
},
// {id, label} pairs for the code-type button group (see CODE_TYPE_LABELS above), in
// label.js's QR_CODE_TYPES order.
codeTypeOptions() {
return QR_CODE_TYPES.map(id => ({id, label: CODE_TYPE_LABELS[id]}));
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
derivedVars() {
return Object.keys(DERIVED_VARS);
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
},
// Named content fields the templates draw from: the form's own base vars, plus every
// DERIVED_VARS format string calculated live from those - so typing a userHandle and
@ -445,7 +420,15 @@ export default {
base[v] = this.varValues[v];
}
}
return withDerivedVars(base);
const derived = withDerivedVars(base);
const shortId = this.shortId(derived);
if (shortId) {
derived[SHORT_ID_VAR] = shortId;
if (derived.webdomain) {
derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
}
}
return derived;
},
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
@ -546,13 +529,6 @@ export default {
this.redrawFallback();
}
},
codeType() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
// Covers connecting/disconnecting/switching printers - anything that changes the
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
@ -565,6 +541,8 @@ export default {
},
},
methods: {
...mapActions(["fetchIdMap"]),
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
// e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
// require hand-writing a label for it here.
@ -572,6 +550,36 @@ export default {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
// The same shortened link Inventory.vue/StorageLocation.vue's own shortIdLink builds for
// one of their rows, but as the bare token (see short-id.js's encodeShortId) rather than a
// router target or full URL - shortenedRoute's own leading "/" is stripped since this is a
// plain display field/potential label content, not something this view itself navigates
// to; fields() below reattaches a domain and slash to build shortUrl from this same value,
// so the two can never disagree. Which short-id.js kind applies depends on which id field
// the current prefill's LABEL_FIELD_BUILDERS populated (see label.js) - itemId for an
// item, locationId for a storage location - rather than trusting the prefill's own `kind`
// directly, so hand-typing userHandle+itemId with no prefill at all still resolves this
// the same way. Falls back to no value - same as an unresolved DERIVED_VARS entry - until
// identityIdByHandle has loaded (see mounted's fetchIdMap) or if the handle isn't in it.
shortId(f) {
if (!f.userHandle) {
return null;
}
const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) {
return null;
}
if (f.itemId) {
return shortenedRoute({kind: "item", owner_identity_id, item_local_id: f.itemId}).slice(1);
}
if (f.locationId) {
return shortenedRoute({
kind: "storage_location", owner_identity_id, storage_location_id: f.locationId
}).slice(1);
}
return null;
},
// Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
// by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the
// labeled/major one come from the shared rulerTier (see above), not from this totalMm, so
@ -663,7 +671,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement);
let textSizesPx;
try {
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation, this.codeType));
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation));
} catch (e) {
this.error = e.message;
return;
@ -689,7 +697,7 @@ export default {
}
this.resizeObserver.observe(canvas.parentElement);
try {
drawFallbackLabel(canvas, content, this.orientation, this.codeType);
drawFallbackLabel(canvas, content, this.orientation);
} catch (e) {
this.error = e.message;
return;
@ -823,10 +831,16 @@ export default {
},
async mounted() {
this.drawCandidateFontTests();
// Not awaited: itemShortUrl just reads whatever identityIdByHandle currently holds (see
// the fields computed), so this resolving after first render only means it shows ""
// briefly rather than blocking the rest of mounted's (unrelated) printer/wasm setup.
this.fetchIdMap().catch(e => {
this.error = e.message;
});
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Kicked off here rather than awaited immediately, so it loads concurrently with
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every
// redraw()/redrawFallback() call below still waits on it first, since a "qrcode" leaf
// redraw()/redrawFallback() call below still waits on it first, since a qr/mqr/rmqr leaf
// throws (see label.js's encodeQr) until it resolves.
const qrReady = preloadQrEncoder();
if (!("usb" in navigator)) {

View file

@ -14,10 +14,57 @@
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from image</h5>
<h5 class="card-title mb-0">Results</h5>
</div>
<div class="card-body">
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<label class="form-label">Camera</label>
<select class="form-control" v-model="selectedCameraId">
<option value="">Auto (prefer recent, then rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div v-if="cameraRunning" class="col-auto">
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
@change="onFileInputChange">
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
@ -40,58 +87,6 @@
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<label class="form-label">Camera</label>
<select class="form-control" v-model="selectedCameraId"
:disabled="cameraRunning">
<option value="">Auto (prefer rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ c.label || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div class="col-auto">
<button v-if="!cameraRunning" class="btn btn-primary"
:disabled="!insecureContext" @click="startCamera">
<b-icon-camera class="me-1"></b-icon-camera>
Start camera
</button>
<button v-else class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</main>
@ -102,6 +97,7 @@
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
import cameraManager from "@/cameraManager.js";
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
@ -145,12 +141,27 @@ export default {
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
selectedCameraId: "",
localSelectedCameraId: "",
cameraRunning: false,
cameraRes: "",
cameraLog: [],
};
},
computed: {
// Switches camera the moment the select changes, rather than waiting for an explicit
// "apply" step - matches prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
selectedCameraId: {
get() {
return this.localSelectedCameraId;
},
set(value) {
this.localSelectedCameraId = value;
if (this.cameraRunning) {
this.switchCamera(value || null);
}
},
},
},
methods: {
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
// shared by the file input, drag&drop and paste handlers below rather than each
@ -229,8 +240,17 @@ export default {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
this.cameras = devices.filter(d => d.kind === "videoinput");
await cameraManager.enumerateDevices();
this.cameras = cameraManager.getAvailableCameras();
},
cleanCameraLabel(label) {
if (!label) return "";
const parts = label.split(":").map((p) => p.trim());
if (parts.length === 2 && parts[0] === parts[1]) {
return parts[0];
}
return label;
},
syncOverlaySize() {
@ -260,6 +280,7 @@ export default {
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
// has moved out of frame.
drawOverlay(codes) {
console.log("drawOverlay", codes);
const overlay = this.$refs.overlay;
const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) {
@ -286,26 +307,43 @@ export default {
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
// Attaches `stream` to the video element - shared by startCamera and the
// camera-switch/reconnect paths so the scanner (which just keeps reading frames off the
// same video element) never needs to be recreated. videoWidth/videoHeight aren't known yet
// right after play() - the video's own "resize" event (see onVideoResize) is what tells us
// the new aspect ratio has actually taken effect, which is also when the overlay needs to
// be resized to match.
setupVideoStream(stream) {
const video = this.$refs.video;
if (!video) return;
video.srcObject = stream;
video.play();
},
// Fires on the video element's own "resize"/"loadedmetadata" events - i.e. whenever its
// intrinsic width/height actually change (initial load, or switching to a camera with a
// different native resolution/aspect ratio) - so the overlay canvas is resized to match the
// video's *new* rendered size rather than the stale one from before the switch.
onVideoResize() {
const video = this.$refs.video;
if (!video || !video.videoWidth) {
return;
}
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
},
async startCamera() {
this.error = null;
try {
const constraints = {
video: this.selectedCameraId
? {deviceId: {exact: this.selectedCameraId}}
: {facingMode: {ideal: "environment"}},
audio: false,
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = this.$refs.video;
video.srcObject = this.stream;
await video.play();
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
this.setupVideoStream(stream);
await this.populateCameraList();
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(video, {
fps: 12,
this.scanner = anyd.createCameraScanner(this.$refs.video, {
fps: 4,
downscale: 2,
onDecode: (codes) => {
codes.forEach(this.logDecode);
@ -320,11 +358,20 @@ export default {
}
},
async switchCamera(cameraId) {
if (!this.cameraRunning) return;
try {
const stream = await cameraManager.switchCamera(cameraId);
if (stream) this.setupVideoStream(stream);
} catch (e) {
this.error = `Camera error: ${e.message ?? e}`;
}
},
stopCamera() {
this.scanner?.stop();
this.scanner = null;
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
cameraManager.closeStream();
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
@ -334,25 +381,56 @@ export default {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
},
// The camera manager already tries a fallback device on disconnect (see cameraManager.js)
// and only fires 'camera-disconnected' once none is left, so by the time this runs there's
// nothing left to fall back to and the running scan session has to stop.
handleCameraDisconnected(event) {
if (!this.cameraRunning) return;
this.error = `Camera error: ${event.detail?.error || "Camera disconnected"}`;
this.stopCamera();
},
handleCameraReconnected() {
if (!this.cameraRunning) return;
const stream = cameraManager.getActiveStream();
if (!stream) return;
this.error = null;
this.setupVideoStream(stream);
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
},
},
created() {
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
this.anydPromise = loadAnyDCode();
this.stream = null;
this.scanner = null;
this.clearOverlayTimer = null;
},
mounted() {
window.addEventListener("paste", this.onPaste);
window.addEventListener("resize", this.syncOverlaySize);
this.populateCameraList();
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.addEventListener("resize", this.onVideoResize);
if (this.cameraSupported && this.insecureContext) {
this.startCamera();
} else {
this.populateCameraList();
}
},
beforeUnmount() {
this.stopCamera();
window.removeEventListener("paste", this.onPaste);
window.removeEventListener("resize", this.syncOverlaySize);
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer);
},
}
@ -412,7 +490,6 @@ export default {
display: flex;
flex-direction: column;
gap: .4rem;
max-height: 260px;
overflow-y: auto;
}

View file

@ -46,6 +46,9 @@
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
@ -79,6 +82,10 @@
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
@ -126,6 +133,14 @@ export default {
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
},
// Routes to Print.vue with this location's own raw identity - userHandle + id - rather
// than any pre-built link, the same shape Inventory.vue's printLinkFor sends for an item
// (see label.js's "storage-location" LABEL_FIELD_BUILDERS entry). Locations are always
// individually owned (see StorageLocationViewSet.get_queryset), so unlike Inventory.vue's
// version this never has to fall back to "no metadata available".
printLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}}
},
},
async mounted() {
await this.fetchStorageLocations()