mirror of
https://github.com/hay-kot/homebox.git
synced 2024-11-16 21:58:40 +00:00
feat: mvp for label generation/printing (#274)
* initial label generator for QR codes * use dynamic URL parameter
This commit is contained in:
parent
c0953bbd26
commit
ff75daf6b3
2 changed files with 454 additions and 1 deletions
433
frontend/pages/reports/label-generator.vue
Normal file
433
frontend/pages/reports/label-generator.vue
Normal file
|
@ -0,0 +1,433 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ["auth"],
|
||||||
|
layout: false,
|
||||||
|
});
|
||||||
|
useHead({
|
||||||
|
title: "Homebox | Printer",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bordered = ref(false);
|
||||||
|
|
||||||
|
const displayProperties = reactive({
|
||||||
|
baseURL: window.location.origin,
|
||||||
|
assetRange: 1,
|
||||||
|
assetRangeMax: 91,
|
||||||
|
gapY: 0.25,
|
||||||
|
columns: 3,
|
||||||
|
cardHeight: 1,
|
||||||
|
cardWidth: 2.63,
|
||||||
|
pageWidth: 8.5,
|
||||||
|
pageHeight: 11,
|
||||||
|
pageTopPadding: 0.52,
|
||||||
|
pageBottomPadding: 0.42,
|
||||||
|
pageLeftPadding: 0.25,
|
||||||
|
pageRightPadding: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Input = {
|
||||||
|
page: {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
pageTopPadding: number;
|
||||||
|
pageBottomPadding: number;
|
||||||
|
pageLeftPadding: number;
|
||||||
|
pageRightPadding: number;
|
||||||
|
};
|
||||||
|
cardHeight: number;
|
||||||
|
cardWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Output = {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
gapY: number;
|
||||||
|
gapX: number;
|
||||||
|
card: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
page: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pt: number;
|
||||||
|
pb: number;
|
||||||
|
pl: number;
|
||||||
|
pr: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifier = useNotifier();
|
||||||
|
|
||||||
|
function calculateGridData(input: Input): Output {
|
||||||
|
const { page, cardHeight, cardWidth } = input;
|
||||||
|
|
||||||
|
const availablePageWidth = page.width - page.pageLeftPadding - page.pageRightPadding;
|
||||||
|
const availablePageHeight = page.height - page.pageTopPadding - page.pageBottomPadding;
|
||||||
|
|
||||||
|
if (availablePageWidth < cardWidth || availablePageHeight < cardHeight) {
|
||||||
|
notifier.error("Page size is too small for the card size");
|
||||||
|
return out.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = Math.floor(availablePageWidth / cardWidth);
|
||||||
|
const rows = Math.floor(availablePageHeight / cardHeight);
|
||||||
|
const gapX = (availablePageWidth - cols * cardWidth) / (cols - 1);
|
||||||
|
const gapY = (page.height - rows * cardHeight) / (rows - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
gapX,
|
||||||
|
gapY,
|
||||||
|
card: {
|
||||||
|
width: cardWidth,
|
||||||
|
height: cardHeight,
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
width: page.width,
|
||||||
|
height: page.height,
|
||||||
|
pt: page.pageTopPadding,
|
||||||
|
pb: page.pageBottomPadding,
|
||||||
|
pl: page.pageLeftPadding,
|
||||||
|
pr: page.pageRightPadding,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InputDef {
|
||||||
|
label: string;
|
||||||
|
ref: keyof typeof displayProperties;
|
||||||
|
type?: "number" | "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyInputs = computed<InputDef[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Asset Start",
|
||||||
|
ref: "assetRange",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Asset End",
|
||||||
|
ref: "assetRangeMax",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Label Height",
|
||||||
|
ref: "cardHeight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Label Width",
|
||||||
|
ref: "cardWidth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Page Width",
|
||||||
|
ref: "pageWidth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Page Height",
|
||||||
|
ref: "pageHeight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Page Top Padding",
|
||||||
|
ref: "pageTopPadding",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Page Bottom Padding",
|
||||||
|
ref: "pageBottomPadding",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Page Left Padding",
|
||||||
|
ref: "pageLeftPadding",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Page Right Padding",
|
||||||
|
ref: "pageRightPadding",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Base URL",
|
||||||
|
ref: "baseURL",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
type LabelData = {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
assetID: string;
|
||||||
|
location: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
|
function fmtAssetID(aid: number | string) {
|
||||||
|
aid = aid.toString();
|
||||||
|
|
||||||
|
let aidStr = aid.toString().padStart(6, "0");
|
||||||
|
aidStr = aidStr.slice(0, 3) + "-" + aidStr.slice(3);
|
||||||
|
return aidStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQRCodeUrl(assetID: string): string {
|
||||||
|
let origin = displayProperties.baseURL.trim();
|
||||||
|
|
||||||
|
// remove trailing slash
|
||||||
|
if (origin.endsWith("/")) {
|
||||||
|
origin = origin.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = `${origin}/a/${assetID}`;
|
||||||
|
|
||||||
|
return `/api/v1/qrcode?data=${encodeURIComponent(data)}&access_token=${api.items.attachmentToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItem(n: number): LabelData {
|
||||||
|
// format n into - seperated string with leading zeros
|
||||||
|
|
||||||
|
const assetID = fmtAssetID(n);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: getQRCodeUrl(assetID),
|
||||||
|
assetID,
|
||||||
|
name: "_______________",
|
||||||
|
location: "_______________",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = computed(() => {
|
||||||
|
if (displayProperties.assetRange > displayProperties.assetRangeMax) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = displayProperties.assetRangeMax - displayProperties.assetRange;
|
||||||
|
|
||||||
|
if (diff > 999) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: LabelData[] = [];
|
||||||
|
for (let i = displayProperties.assetRange; i < displayProperties.assetRangeMax; i++) {
|
||||||
|
items.push(getItem(i));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
items: LabelData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Page = {
|
||||||
|
rows: Row[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = ref<Page[]>([]);
|
||||||
|
|
||||||
|
const out = ref({
|
||||||
|
cols: 0,
|
||||||
|
rows: 0,
|
||||||
|
gapY: 0,
|
||||||
|
gapX: 0,
|
||||||
|
card: {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
pt: 0,
|
||||||
|
pb: 0,
|
||||||
|
pl: 0,
|
||||||
|
pr: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function calcPages() {
|
||||||
|
// Set Out Dimensions
|
||||||
|
out.value = calculateGridData({
|
||||||
|
page: {
|
||||||
|
height: displayProperties.pageHeight,
|
||||||
|
width: displayProperties.pageWidth,
|
||||||
|
pageTopPadding: displayProperties.pageTopPadding,
|
||||||
|
pageBottomPadding: displayProperties.pageBottomPadding,
|
||||||
|
pageLeftPadding: displayProperties.pageLeftPadding,
|
||||||
|
pageRightPadding: displayProperties.pageRightPadding,
|
||||||
|
},
|
||||||
|
cardHeight: displayProperties.cardHeight,
|
||||||
|
cardWidth: displayProperties.cardWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calc: Page[] = [];
|
||||||
|
|
||||||
|
const perPage = out.value.rows * out.value.cols;
|
||||||
|
|
||||||
|
const itemsCopy = [...items.value];
|
||||||
|
|
||||||
|
while (itemsCopy.length > 0) {
|
||||||
|
const page: Page = {
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < perPage; i++) {
|
||||||
|
const item = itemsCopy.shift();
|
||||||
|
if (!item) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i % out.value.cols === 0) {
|
||||||
|
page.rows.push({
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page.rows[page.rows.length - 1].items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
calc.push(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.value = calc;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
calcPages();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="print:hidden">
|
||||||
|
<AppToast />
|
||||||
|
<div class="container max-w-4xl mx-auto p-4 pt-6 prose">
|
||||||
|
<h1>Homebox Label Generator</h1>
|
||||||
|
<p>
|
||||||
|
The Homebox Label Generator is a tool to help you print labels for your Homebox inventory. These are intended to
|
||||||
|
be print-ahead labels so you can print many labels and have them ready to apply
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
As such, these labels work by printing a URL QR Code and AssetID information on a label. If you've disabled
|
||||||
|
AssetID's in your Homebox settings, you can still use this tool, but the AssetID's won't reference any item
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This feature is in early development stages and may change in future releases, if you have feedback please
|
||||||
|
provide it in the <a href="https://github.com/hay-kot/homebox/discussions/273">GitHub Discussion</a>
|
||||||
|
</p>
|
||||||
|
<h2>Tips</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The defaults here are setup for the
|
||||||
|
<a href="https://www.avery.com/templates/5260">Avery 5260 label sheets</a>. If you're using a different sheet,
|
||||||
|
you'll need to adjust the settings to match your sheet.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If you're customizing your sheet the dimensions are in inches. When building the 5260 sheet, I found that the
|
||||||
|
dimensions used in their template, did not match what was needed to print within the boxes.
|
||||||
|
<b>Be prepared for some trial and error</b>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
When printing be sure to:
|
||||||
|
<ol>
|
||||||
|
<li>Set the margins to 0 or None</li>
|
||||||
|
<li>Set the scaling to 100%</li>
|
||||||
|
<li>Disable double-sided printing</li>
|
||||||
|
<li>Print a test page before printing multiple pages</li>
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<NuxtLink href="/tools">Tools</NuxtLink>
|
||||||
|
<NuxtLink href="/home">Home</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider max-w-4xl mx-auto"></div>
|
||||||
|
<div class="container max-w-4xl mx-auto p-4">
|
||||||
|
<div class="grid grid-cols-2 mx-auto gap-3">
|
||||||
|
<div v-for="(prop, i) in propertyInputs" :key="i" class="form-control w-full max-w-xs">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">{{ prop.label }}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="displayProperties[prop.ref]"
|
||||||
|
:type="prop.type ? prop.type : 'number'"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Type here"
|
||||||
|
class="input input-bordered w-full max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-xs">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="cursor-pointer label">
|
||||||
|
<input v-model="bordered" type="checkbox" class="checkbox checkbox-secondary" />
|
||||||
|
<span class="label-text">Bordered Labels</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>QR Code Example {{ displayProperties.baseURL }}/a/{asset_id}</p>
|
||||||
|
<BaseButton class="btn-block my-4" @click="calcPages"> Generate Page </BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center print-show">
|
||||||
|
<section
|
||||||
|
v-for="(page, pi) in pages"
|
||||||
|
:key="pi"
|
||||||
|
class="border-2 print:border-none"
|
||||||
|
:style="{
|
||||||
|
paddingTop: `${out.page.pt}in`,
|
||||||
|
paddingBottom: `${out.page.pb}in`,
|
||||||
|
paddingLeft: `${out.page.pl}in`,
|
||||||
|
paddingRight: `${out.page.pr}in`,
|
||||||
|
width: `${out.page.width}in`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(row, ri) in page.rows"
|
||||||
|
:key="ri"
|
||||||
|
class="flex break-inside-avoid"
|
||||||
|
:style="{
|
||||||
|
columnGap: `${out.gapX}in`,
|
||||||
|
rowGap: `${out.gapY}in`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in row.items"
|
||||||
|
:key="idx"
|
||||||
|
class="flex border-2"
|
||||||
|
:class="{
|
||||||
|
'border-black': bordered,
|
||||||
|
'border-transparent': !bordered,
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
height: `${out.card.height}in`,
|
||||||
|
width: `${out.card.width}in`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img
|
||||||
|
:src="item.url"
|
||||||
|
:style="{
|
||||||
|
width: `${out.card.height * 0.9}in`,
|
||||||
|
height: `${out.card.height * 0.9}in`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex flex-col justify-center">
|
||||||
|
<div class="font-bold">{{ item.assetID }}</div>
|
||||||
|
<div class="text-xs font-light italic">Homebox</div>
|
||||||
|
<div>{{ item.name }}</div>
|
||||||
|
<div>{{ item.location }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
.letter-size {
|
||||||
|
width: 8.5in;
|
||||||
|
height: 11in;
|
||||||
|
padding: 0.5in;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -13,7 +13,7 @@
|
||||||
</template>
|
</template>
|
||||||
</BaseSectionHeader>
|
</BaseSectionHeader>
|
||||||
<div class="border-t border-gray-300 divide-gray-300 divide-y">
|
<div class="border-t border-gray-300 divide-gray-300 divide-y">
|
||||||
<DetailAction @click="modals.import = true">
|
<DetailAction @action="modals.import = true">
|
||||||
<template #title>Import Inventory</template>
|
<template #title>Import Inventory</template>
|
||||||
Imports the standard CSV format for Homebox. This will <b>not</b> overwrite any existing items in your
|
Imports the standard CSV format for Homebox. This will <b>not</b> overwrite any existing items in your
|
||||||
inventory. It will only add new items.
|
inventory. It will only add new items.
|
||||||
|
@ -25,6 +25,26 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
|
<BaseCard>
|
||||||
|
<template #title>
|
||||||
|
<BaseSectionHeader>
|
||||||
|
<Icon name="mdi-file-chart" class="mr-2 -mt-1" />
|
||||||
|
<span> Reports </span>
|
||||||
|
<template #description> Generate different reports for your inventory. </template>
|
||||||
|
</BaseSectionHeader>
|
||||||
|
<div class="border-t border-gray-300 divide-gray-300 divide-y">
|
||||||
|
<DetailAction @action="navigateTo('/reports/label-generator')">
|
||||||
|
<template #title>Asset ID Labels</template>
|
||||||
|
Generates a printable PDF of labels for a range of Asset ID. These are not specific to your invetory so
|
||||||
|
your are able to print labels ahead of time and apply them to your inventory when you receive them.
|
||||||
|
<template #button>
|
||||||
|
Label Generator
|
||||||
|
<Icon name="mdi-arrow-right" class="ml-2" />
|
||||||
|
</template>
|
||||||
|
</DetailAction>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BaseCard>
|
||||||
<BaseCard>
|
<BaseCard>
|
||||||
<template #title>
|
<template #title>
|
||||||
<BaseSectionHeader>
|
<BaseSectionHeader>
|
||||||
|
|
Loading…
Reference in a new issue