Add batch print page for printing multiple device labels at once
Deploy to LXC / deploy (push) Successful in 20s

- /batch-print page with device table, checkboxes, search, category filter
- Select all / individual selection with highlighted rows
- "Print N Labels" button opens popup with all selected labels
- /print/batch renders one DK-11209 label per device with page breaks
- Auto-print on popup open, last label avoids trailing blank page
- Sidebar nav item added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 18:02:13 +07:00
parent c7f7be64c4
commit 510eb719eb
5 changed files with 254 additions and 0 deletions
+5
View File
@@ -48,6 +48,11 @@
label: 'Checklists',
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4'
},
{
href: '/batch-print',
label: 'Batch Print',
icon: 'M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z'
},
{
href: '/gallery',
label: 'Gallery',
@@ -0,0 +1,21 @@
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';
export const load: PageServerLoad = async () => {
const deviceList = 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.disabled, false))
.orderBy(devices.title);
return { devices: deviceList };
};
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { DEVICE_CATEGORIES } from '$lib/constants.js';
let { data } = $props();
let selected = $state<Set<string>>(new Set());
let categoryFilter = $state('');
let search = $state('');
const filtered = $derived(
data.devices.filter((d) => {
if (categoryFilter && d.category !== categoryFilter) return false;
if (search) {
const q = search.toLowerCase();
return (
d.title.toLowerCase().includes(q) ||
(d.brand ?? '').toLowerCase().includes(q) ||
(d.model ?? '').toLowerCase().includes(q) ||
(d.serialNumber ?? '').toLowerCase().includes(q)
);
}
return true;
})
);
function toggleAll() {
if (selected.size === filtered.length) {
selected = new Set();
} else {
selected = new Set(filtered.map((d) => d.id));
}
}
function toggle(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
}
function printSelected() {
const ids = [...selected].join(',');
window.open(`/print/batch?ids=${ids}`, '_blank', 'width=600,height=800');
}
const allSelected = $derived(filtered.length > 0 && selected.size === filtered.length);
</script>
<svelte:head>
<title>Batch Print - My Collection</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Batch Print Labels</h1>
<button type="button" onclick={printSelected} disabled={selected.size === 0}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed">
Print {selected.size} Label{selected.size !== 1 ? 's' : ''}
</button>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<input type="text" bind:value={search} placeholder="Search..."
class="flex-1 max-w-sm rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
<select bind:value={categoryFilter}
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Categories</option>
{#each DEVICE_CATEGORIES as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
<span class="text-sm text-gray-500 dark:text-gray-400">{filtered.length} device{filtered.length !== 1 ? 's' : ''}</span>
</div>
<!-- Device list -->
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
<th class="w-10 px-4 py-2">
<input type="checkbox" checked={allSelected} onchange={toggleAll}
class="h-4 w-4 rounded border-gray-300 dark:border-gray-600" />
</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Device</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Category</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 dark:text-gray-400">Serial</th>
</tr>
</thead>
<tbody>
{#each filtered as device}
<tr class="border-b border-gray-100 last:border-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/30
{selected.has(device.id) ? 'bg-blue-50 dark:bg-blue-900/10' : ''}">
<td class="px-4 py-3">
<input type="checkbox" checked={selected.has(device.id)} onchange={() => toggle(device.id)}
class="h-4 w-4 rounded border-gray-300 dark:border-gray-600" />
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900 dark:text-white">{device.title}</span>
{#if device.brand || device.model}
<span class="ml-1 text-gray-400 dark:text-gray-500">{[device.brand, device.model].filter(Boolean).join(' ')}</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{device.category}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400">{device.serialNumber ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,37 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { devices } from '$lib/server/db/schema.js';
import { sql } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { generateQrSvg } from '$lib/server/qr.js';
export const load: PageServerLoad = async ({ url }) => {
const idsParam = url.searchParams.get('ids');
if (!idsParam) error(400, 'No devices selected');
const ids = idsParam.split(',').filter(Boolean);
if (ids.length === 0) error(400, 'No devices selected');
const deviceList = await db
.select({
id: devices.id,
title: devices.title,
brand: devices.brand,
model: devices.model,
serialNumber: devices.serialNumber,
category: devices.category
})
.from(devices)
.where(sql`${devices.id} IN ${ids}`);
// Generate QR codes for each
const labels = await Promise.all(
deviceList.map(async (device) => {
const shortId = device.id.slice(0, 8).toUpperCase();
const qrSvg = await generateQrSvg(shortId);
return { ...device, qrSvg, shortId };
})
);
return { labels };
};
@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
let { data } = $props();
onMount(() => {
if (window.opener) {
setTimeout(() => window.print(), 500);
}
});
</script>
<svelte:head>
<title>Batch Print - {data.labels.length} Labels</title>
</svelte:head>
<div class="no-print" style="max-width: 500px; margin: 20px auto; font-family: system-ui, sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<a href="/batch-print" style="color: #2563eb; font-size: 14px;">&#8592; Back</a>
<button onclick={() => window.print()}
style="background: #2563eb; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer;">
Print {data.labels.length} Labels
</button>
</div>
<div style="background: #f3f4f6; padding: 8px 12px; border-radius: 6px; font-size: 12px; color: #666; margin-bottom: 16px;">
<strong>Setup:</strong> Brother QL-820NWB, paper "29mm x 62mm" (DK-11209), margins minimum.
</div>
</div>
{#each data.labels as label}
<div class="label" style="width: 62mm; height: 29mm; background: white; color: black; box-sizing: border-box; padding: 1mm; margin: 0 auto 8px auto; border: 1px dashed #ccc; overflow: hidden;">
<div style="display: flex; align-items: center; gap: 2mm; height: 100%; font-family: Arial, Helvetica, sans-serif;">
<div style="width: 22mm; height: 22mm; flex-shrink: 0;">
{@html label.qrSvg}
</div>
<div style="flex: 1; min-width: 0; text-align: center;">
<div style="font-size: 9pt; font-weight: bold; color: #000; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{label.title}
</div>
{#if label.brand || label.model}
<div style="font-size: 6pt; color: #444; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{[label.brand, label.model].filter(Boolean).join(' ')}
</div>
{/if}
{#if label.serialNumber}
<div style="font-size: 6pt; color: #666; margin-top: 0.3mm;">
S/N: {label.serialNumber}
</div>
{/if}
<div style="font-size: 11pt; font-weight: bold; color: #000; margin-top: 0.5mm; letter-spacing: 1px;">
{label.shortId}
</div>
</div>
</div>
</div>
{/each}
<style>
@page {
size: 62mm 29mm;
margin: 0;
}
@media print {
.no-print { display: none !important; }
:global(html), :global(body) {
margin: 0 !important;
padding: 0 !important;
height: auto !important;
min-height: 0 !important;
}
.label {
border: none !important;
margin: 0 !important;
page-break-after: always;
}
.label:last-child {
page-break-after: avoid;
}
}
</style>