Add DK-22210 label printing with barcode and QR code
- Print page formatted for Brother DK-22210 (29mm continuous tape) - Each label has: title, brand/model, serial, QR code, Code 128 barcode - CSS @page sized to 29mm width with minimal margins - Print button opens popup that auto-triggers print dialog - Copies selector to print multiple labels at once - Barcode encodes short ID (first 8 chars) scannable by the lookup endpoint - Available on both device and component detail pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -11,6 +11,7 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
|
"bwip-js": "^4.9.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.38.4",
|
||||||
@@ -2649,6 +2650,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bwip-js": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-U3aWIxR/Px4m3GPd0opQ5GQJq/G8Cj0cr5z5hrcvy/SAApPnfkLqBqjRuB3GiEAasEQup3m7k/MDM/uiS9te8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"bwip-js": "bin/bwip-js.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelcase": {
|
"node_modules/camelcase": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
|
"bwip-js": "^4.9.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.38.4",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// @ts-expect-error bwip-js types not resolved by bundler moduleResolution
|
||||||
|
import bwipjs from 'bwip-js';
|
||||||
|
|
||||||
|
export async function generateBarcodeSvg(text: string): Promise<string> {
|
||||||
|
const buf = await bwipjs.toBuffer({
|
||||||
|
bcid: 'code128',
|
||||||
|
text,
|
||||||
|
scale: 3,
|
||||||
|
height: 8,
|
||||||
|
includetext: true,
|
||||||
|
textsize: 8,
|
||||||
|
textxalign: 'center'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return as base64 data URL for embedding in HTML
|
||||||
|
return `data:image/png;base64,${buf.toString('base64')}`;
|
||||||
|
}
|
||||||
@@ -35,8 +35,12 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="/components/{c.id}/label"
|
<a href="/components/{c.id}/label"
|
||||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||||
QR Label
|
Label
|
||||||
</a>
|
</a>
|
||||||
|
<button onclick={() => window.open(`/components/${c.id}/print`, '_blank', 'width=400,height=600')}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
{#if c.currentDeviceId}
|
{#if c.currentDeviceId}
|
||||||
<a href="/installations/new?componentId={c.id}&action=removed"
|
<a href="/installations/new?componentId={c.id}&action=removed"
|
||||||
class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { components } from '$lib/server/db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { generateQrSvg } from '$lib/server/qr.js';
|
||||||
|
import { generateBarcodeSvg } from '$lib/server/barcode.js';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const [component] = await db
|
||||||
|
.select({
|
||||||
|
id: components.id,
|
||||||
|
title: components.title,
|
||||||
|
componentType: components.componentType,
|
||||||
|
brand: components.brand,
|
||||||
|
partNumber: components.partNumber,
|
||||||
|
serialNumber: components.serialNumber
|
||||||
|
})
|
||||||
|
.from(components)
|
||||||
|
.where(eq(components.id, params.id));
|
||||||
|
|
||||||
|
if (!component) error(404, 'Component not found');
|
||||||
|
|
||||||
|
const url = `${env.BASE_URL ?? 'http://localhost:5173'}/components/${params.id}`;
|
||||||
|
const qrSvg = await generateQrSvg(url);
|
||||||
|
const shortId = component.id.slice(0, 8).toUpperCase();
|
||||||
|
const barcodeDataUrl = await generateBarcodeSvg(shortId);
|
||||||
|
|
||||||
|
return { component, qrSvg, barcodeDataUrl, shortId };
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let copies = $state(1);
|
||||||
|
|
||||||
|
function printNow() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (window.opener) {
|
||||||
|
setTimeout(() => window.print(), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Print Label - {data.component.title}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: 29mm auto;
|
||||||
|
margin: 1mm;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
nav, header, aside { display: none !important; }
|
||||||
|
main { padding: 0 !important; overflow: visible !important; }
|
||||||
|
.print-container { break-inside: avoid; }
|
||||||
|
}
|
||||||
|
@media screen {
|
||||||
|
.label-preview {
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
width: 29mm;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="no-print mx-auto max-w-lg">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<a href="/components/{data.component.id}" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Back</a>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Copies:
|
||||||
|
<input type="number" bind:value={copies} min="1" max="20"
|
||||||
|
class="ml-1 w-14 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||||
|
</label>
|
||||||
|
<button onclick={printNow}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2 rounded-md bg-gray-100 p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||||
|
<strong>Printer setup:</strong> Select your Brother printer, paper size "DK-22210 (29mm)" or "29mm" roll. Set margins to minimum.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">Preview ({copies} label{copies > 1 ? 's' : ''}):</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each Array(copies) as _, i}
|
||||||
|
<div class="print-container label-preview mx-auto mb-2 p-1">
|
||||||
|
<div class="text-center" style="font-family: Arial, Helvetica, sans-serif;">
|
||||||
|
<div style="font-size: 8pt; font-weight: bold; line-height: 1.2; margin-bottom: 1mm;">
|
||||||
|
{data.component.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 6pt; color: #555; margin-bottom: 1mm;">
|
||||||
|
{data.component.componentType}
|
||||||
|
{#if data.component.brand}· {data.component.brand}{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.component.partNumber}
|
||||||
|
<div style="font-size: 5pt; color: #777; margin-bottom: 1mm;">
|
||||||
|
P/N: {data.component.partNumber}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.component.serialNumber}
|
||||||
|
<div style="font-size: 5pt; color: #777; margin-bottom: 1mm;">
|
||||||
|
S/N: {data.component.serialNumber}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: center; margin-bottom: 1mm;">
|
||||||
|
<div style="width: 18mm; height: 18mm;">
|
||||||
|
{@html data.qrSvg}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: center; margin-bottom: 0.5mm;">
|
||||||
|
<img src={data.barcodeDataUrl} alt={data.shortId} style="width: 25mm; height: auto;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
@@ -60,8 +60,12 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="/devices/{data.device.id}/label"
|
<a href="/devices/{data.device.id}/label"
|
||||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||||
QR Label
|
Label
|
||||||
</a>
|
</a>
|
||||||
|
<button onclick={() => window.open(`/devices/${data.device.id}/print`, '_blank', 'width=400,height=600')}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
<a href="/installations/new?deviceId={data.device.id}"
|
<a href="/installations/new?deviceId={data.device.id}"
|
||||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||||
Install Component
|
Install Component
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { devices } from '$lib/server/db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { generateQrSvg } from '$lib/server/qr.js';
|
||||||
|
import { generateBarcodeSvg } from '$lib/server/barcode.js';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const [device] = await db
|
||||||
|
.select({
|
||||||
|
id: devices.id,
|
||||||
|
title: devices.title,
|
||||||
|
brand: devices.brand,
|
||||||
|
model: devices.model,
|
||||||
|
serialNumber: devices.serialNumber,
|
||||||
|
category: devices.category
|
||||||
|
})
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.id, params.id));
|
||||||
|
|
||||||
|
if (!device) error(404, 'Device not found');
|
||||||
|
|
||||||
|
const url = `${env.BASE_URL ?? 'http://localhost:5173'}/devices/${params.id}`;
|
||||||
|
const qrSvg = await generateQrSvg(url);
|
||||||
|
const shortId = device.id.slice(0, 8).toUpperCase();
|
||||||
|
const barcodeDataUrl = await generateBarcodeSvg(shortId);
|
||||||
|
|
||||||
|
return { device, qrSvg, barcodeDataUrl, shortId };
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let copies = $state(1);
|
||||||
|
|
||||||
|
function printNow() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Auto-print if opened as popup
|
||||||
|
if (window.opener) {
|
||||||
|
setTimeout(() => window.print(), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Print Label - {data.device.title}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: 29mm auto;
|
||||||
|
margin: 1mm;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
nav, header, aside { display: none !important; }
|
||||||
|
main { padding: 0 !important; overflow: visible !important; }
|
||||||
|
.print-container { break-inside: avoid; }
|
||||||
|
}
|
||||||
|
@media screen {
|
||||||
|
.label-preview {
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
width: 29mm;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Screen controls -->
|
||||||
|
<div class="no-print mx-auto max-w-lg">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<a href="/devices/{data.device.id}" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Back</a>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Copies:
|
||||||
|
<input type="number" bind:value={copies} min="1" max="20"
|
||||||
|
class="ml-1 w-14 rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||||
|
</label>
|
||||||
|
<button onclick={printNow}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2 rounded-md bg-gray-100 p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||||
|
<strong>Printer setup:</strong> Select your Brother printer, paper size "DK-22210 (29mm)" or "29mm" roll. Set margins to minimum.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">Preview ({copies} label{copies > 1 ? 's' : ''}):</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Labels -->
|
||||||
|
{#each Array(copies) as _, i}
|
||||||
|
<div class="print-container label-preview mx-auto mb-2 p-1">
|
||||||
|
<div class="text-center" style="font-family: Arial, Helvetica, sans-serif;">
|
||||||
|
<!-- Title -->
|
||||||
|
<div style="font-size: 8pt; font-weight: bold; line-height: 1.2; margin-bottom: 1mm;">
|
||||||
|
{data.device.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Brand / Model -->
|
||||||
|
{#if data.device.brand || data.device.model}
|
||||||
|
<div style="font-size: 6pt; color: #555; margin-bottom: 1mm;">
|
||||||
|
{[data.device.brand, data.device.model].filter(Boolean).join(' ')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Serial -->
|
||||||
|
{#if data.device.serialNumber}
|
||||||
|
<div style="font-size: 5pt; color: #777; margin-bottom: 1mm;">
|
||||||
|
S/N: {data.device.serialNumber}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- QR Code -->
|
||||||
|
<div style="display: flex; justify-content: center; margin-bottom: 1mm;">
|
||||||
|
<div style="width: 18mm; height: 18mm;">
|
||||||
|
{@html data.qrSvg}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barcode -->
|
||||||
|
<div style="display: flex; justify-content: center; margin-bottom: 0.5mm;">
|
||||||
|
<img src={data.barcodeDataUrl} alt={data.shortId} style="width: 25mm; height: auto;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
Reference in New Issue
Block a user