Add batch print page for printing multiple device labels at once
Deploy to LXC / deploy (push) Successful in 20s
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:
@@ -48,6 +48,11 @@
|
|||||||
label: 'Checklists',
|
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'
|
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',
|
href: '/gallery',
|
||||||
label: '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 };
|
||||||
|
};
|
||||||
@@ -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;">← 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>
|
||||||
Reference in New Issue
Block a user