Add thermal printer integration with USB and Bluetooth support. Implement receipt printing functionality using @point-of-sale libraries. Create UI components for printer connection management and order receipt printing. Update OrderStatus and app settings to accommodate new features.

This commit is contained in:
Yoga Pangestu 2026-06-12 14:52:54 +07:00
parent 5233890030
commit 93992a0d3a
18 changed files with 1304 additions and 12 deletions

View File

@ -76,7 +76,7 @@ public function availableActions(): array
'label' => 'Selesai',
'destructive' => false,
'permission' => Permission::ORDERS_COMPLETE->value,
'icon_only' => false,
'icon_only' => true,
],
[
'status' => self::CANCELLED->value,

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Settings\SystemSettings;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -37,7 +38,8 @@ public function share(Request $request): array
{
return [
...parent::share($request),
'name' => config('app.name'),
'name' => fn () => app(SystemSettings::class)->app_name,
'address' => fn () => app(SystemSettings::class)->address,
'auth' => fn () => [
'user' => $request->user() ? [
'id' => $request->user()->id,

59
package-lock.json generated
View File

@ -13,6 +13,9 @@
"@inertiajs/vite": "^3.0.0",
"@inertiajs/vue3": "^3.0.0",
"@lucide/vue": "^1.17.0",
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
"@point-of-sale/webbluetooth-receipt-printer": "^2.0.0",
"@point-of-sale/webserial-receipt-printer": "^2.0.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3",
"@vitejs/plugin-vue": "^6.0.0",
@ -1681,7 +1684,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.1.0.tgz",
"integrity": "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==",
"dev": true,
"license": "MIT"
},
"node_modules/@emnapi/core": {
@ -2689,6 +2691,37 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@point-of-sale/codepage-encoder": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@point-of-sale/codepage-encoder/-/codepage-encoder-3.0.2.tgz",
"integrity": "sha512-Sx+0sE/XgPtMUNKScHpwTIZiHNd/+Mg0udACQJVwPsxH9kUAgIkRi9kRlmWBMCruwOJadjKLyKs8MO8Bh+VS/A==",
"license": "MIT"
},
"node_modules/@point-of-sale/receipt-printer-encoder": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@point-of-sale/receipt-printer-encoder/-/receipt-printer-encoder-3.0.3.tgz",
"integrity": "sha512-2+xgs6rwNfrkEw9g4LkgQS6k29qnmo3+hOV7Z2xHNBFn8h4FrwquZwL/dBOTwgmQ9sWEYBkj/bQkg9Rz6lL99w==",
"license": "MIT",
"dependencies": {
"@canvas/image-data": "^1.0.0",
"@point-of-sale/codepage-encoder": "^3.0.0",
"canvas-dither": "^1.0.1",
"canvas-flatten": "^1.0.1",
"resize-image-data": "^0.3.1"
}
},
"node_modules/@point-of-sale/webbluetooth-receipt-printer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@point-of-sale/webbluetooth-receipt-printer/-/webbluetooth-receipt-printer-2.0.0.tgz",
"integrity": "sha512-xtplxsZWuhKa7DFASYzV8PSiRzwzjbQLqAsWSdjpIVkEo9Xmo652Pcu3aKbRUu4Ih2k9Fz4weyUZljOgbyDYkg==",
"license": "MIT"
},
"node_modules/@point-of-sale/webserial-receipt-printer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@point-of-sale/webserial-receipt-printer/-/webserial-receipt-printer-2.0.0.tgz",
"integrity": "sha512-txTb+DTdkrl2c3oxtIAYc7vKGb4fJMPGnC8iCYMETeAhcFZP2yeISyCa0DVgw4YXNCmsOv6mI6aYzqUjuBb+wQ==",
"license": "MIT"
},
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@ -5236,6 +5269,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-dither": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/canvas-dither/-/canvas-dither-1.0.1.tgz",
"integrity": "sha512-wT1RnV2y9SauibcsfMMCagQTnXmyQq2nJXuX5tTEvo0sXm1vVbJ2r8QxSC3fRQMY3ZgZVq1j54cOvmO4u+Lu/A==",
"license": "MIT"
},
"node_modules/canvas-flatten": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/canvas-flatten/-/canvas-flatten-1.0.1.tgz",
"integrity": "sha512-UhHeKeWGS23Bm5teZiHk6NEOme1OKCpv6arFovV89dydt0Xz+U4HkPXAKBZS/hmpR15x5U9tDj1ieMQMTwvCAA==",
"license": "MIT"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -8965,6 +9010,18 @@
"node": ">=0.10.0"
}
},
"node_modules/resize-image-data": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/resize-image-data/-/resize-image-data-0.3.1.tgz",
"integrity": "sha512-6hVRn2S6W1cdycreA6Vth5XRN2NnGs7/RnVpxNw/1OCK8aCoevRFH2WprmQRZDnnH3e6awLv2tTIPuv7/7xeGg==",
"license": "MIT",
"dependencies": {
"@canvas/image-data": "^1.0.0"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",

View File

@ -41,6 +41,9 @@
"@inertiajs/vite": "^3.0.0",
"@inertiajs/vue3": "^3.0.0",
"@lucide/vue": "^1.17.0",
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
"@point-of-sale/webbluetooth-receipt-printer": "^2.0.0",
"@point-of-sale/webserial-receipt-printer": "^2.0.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3",
"@vitejs/plugin-vue": "^6.0.0",

View File

@ -6,8 +6,10 @@ import { createApp, h } from 'vue';
import 'vue-sonner/style.css';
import { Toaster } from '@/components/ui/sonner';
import { useFlashToast } from '@/composables/useFlashToast';
import { restoreConnection } from '@/lib/thermal-printer/stable-transport';
registerSW({ immediate: true });
void restoreConnection();
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

View File

@ -103,9 +103,6 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
<p v-if="order.customer">
{{ order.customer.name }}
</p>
<p v-else>
Tanpa pelanggan
</p>
<p>{{ order.created_at_formatted }}</p>
<p>Oleh {{ order.created_by?.profile?.full_name ?? order.created_by?.username }}</p>
</div>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { Printer } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useThermalPrinter, getPaperSizeLabel } from '@/composables/useThermalPrinter';
import type { PaperSize } from '@/lib/thermal-printer/types';
import type { OrderListItem } from '@/types/order';
const props = defineProps<{
order: OrderListItem;
}>();
const page = usePage();
const open = ref(false);
const processing = ref(false);
const { isConnected, printOrderReceipt, tryReconnect } = useThermalPrinter();
const storeName = computed(() => page.props.name);
const storeAddress = computed(() => page.props.address);
const paperSizes: PaperSize[] = ['58mm', '80mm'];
async function handlePrint(paperSize: PaperSize) {
if (processing.value) {
return;
}
if (!isConnected.value) {
const restored = await tryReconnect();
if (!restored) {
toast.error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
return;
}
}
open.value = false;
processing.value = true;
try {
await printOrderReceipt({
order: props.order,
paperSize,
storeName: storeName.value,
storeAddress: storeAddress.value ?? '',
});
toast.success(`Struk ${props.order.order_number} dikirim ke printer ${getPaperSizeLabel(paperSize)}.`);
} catch (error) {
const message = error instanceof Error
? error.message
: 'Gagal mencetak struk pesanan.';
toast.error(message);
} finally {
processing.value = false;
}
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :disabled="processing" title="Cetak struk">
<Printer class="size-4" />
<span class="sr-only">Cetak struk</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-40 space-y-2 p-3">
<p class="text-sm font-medium">
Ukuran kertas
</p>
<div class="grid grid-cols-2 gap-2">
<Button v-for="paperSize in paperSizes" :key="paperSize" variant="outline" size="sm" class="h-8"
:disabled="processing" @click="handlePrint(paperSize)">
{{ paperSize }}
</Button>
</div>
</PopoverContent>
</Popover>
</template>

View File

@ -0,0 +1,130 @@
<script setup lang="ts">
import { Bluetooth, PlugZap, Usb } from '@lucide/vue';
import { onMounted, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useThermalPrinter } from '@/composables/useThermalPrinter';
const open = ref(false);
const {
isConnected,
connectionLabel,
connecting,
connectUsb,
connectBluetooth,
tryReconnect,
disconnect,
supportsUsb,
supportsBluetooth,
} = useThermalPrinter();
onMounted(async () => {
await tryReconnect();
});
async function handleConnectUsb() {
try {
await connectUsb();
open.value = false;
toast.success('Printer USB terhubung.');
} catch (error) {
const message = error instanceof Error
? error.message
: 'Gagal menghubungkan printer USB.';
toast.error(message);
}
}
async function handleConnectBluetooth() {
try {
await connectBluetooth();
open.value = false;
toast.success('Printer Bluetooth terhubung.');
} catch (error) {
const message = error instanceof Error
? error.message
: 'Gagal menghubungkan printer Bluetooth.';
toast.error(message);
}
}
function handleDisconnect() {
disconnect();
open.value = false;
toast.success('Printer terputus.');
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" class="shrink-0 gap-2 self-start sm:self-center">
<PlugZap class="size-4" />
Printer
<Badge
:variant="isConnected ? 'default' : 'secondary'"
class="rounded-full px-2 text-xs"
>
{{ isConnected ? connectionLabel : 'Offline' }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-64 space-y-3 p-4">
<div class="space-y-1">
<p class="text-sm font-medium">
Koneksi Printer
</p>
<p class="text-muted-foreground text-xs">
Hubungkan printer thermal sebelum mencetak struk.
</p>
</div>
<div v-if="isConnected" class="rounded-md border bg-muted/40 px-3 py-2 text-sm">
Terhubung via <strong>{{ connectionLabel }}</strong>
</div>
<div class="grid gap-2">
<Button
variant="outline"
class="justify-start gap-2"
:disabled="!supportsUsb || connecting"
@click="handleConnectUsb"
>
<Usb class="size-4" />
{{ isConnected && connectionLabel === 'USB' ? 'Hubungkan ulang USB' : 'Hubungkan USB' }}
</Button>
<Button
variant="outline"
class="justify-start gap-2"
:disabled="!supportsBluetooth || connecting"
@click="handleConnectBluetooth"
>
<Bluetooth class="size-4" />
{{ isConnected && connectionLabel === 'Bluetooth' ? 'Hubungkan ulang Bluetooth' : 'Hubungkan Bluetooth' }}
</Button>
</div>
<Button
v-if="isConnected"
variant="ghost"
size="sm"
class="w-full"
:disabled="connecting"
@click="handleDisconnect"
>
Putuskan koneksi
</Button>
</PopoverContent>
</Popover>
</template>

View File

@ -3,6 +3,7 @@ import { Link, router } from '@inertiajs/vue3';
import { Check, Pencil, Send, Trash2, X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import OrderPrintButton from '@/components/admin/manage/orders/OrderPrintButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@ -105,6 +106,8 @@ function actionIcon(status: string) {
<template>
<div class="flex flex-wrap items-center justify-end gap-1">
<OrderPrintButton :order="order" />
<template v-for="action in availableActions" :key="action.status">
<Tooltip v-if="action.icon_only && canPerformAction(action)">
<TooltipTrigger as-child>

View File

@ -0,0 +1,104 @@
import { computed, ref } from 'vue';
import { encodeOrderReceipt } from '@/lib/thermal-printer/encode-order-receipt';
import { normalizeEncoderOptions } from '@/lib/thermal-printer/normalize-encoder-options';
import {
connectBluetooth,
connectionState,
connectUsb,
disconnect as disconnectTransport,
getBluetoothEncoderSettings,
isWebBluetoothSupported,
isWebSerialSupported,
restoreConnection,
sendPrintData,
tryReconnect as tryReconnectTransport,
} from '@/lib/thermal-printer/stable-transport';
import type { PaperSize } from '@/lib/thermal-printer/types';
import type { OrderListItem } from '@/types/order';
const connecting = ref(false);
async function withConnecting<T>(callback: () => Promise<T>): Promise<T> {
connecting.value = true;
try {
return await callback();
} finally {
connecting.value = false;
}
}
export type PrintOrderReceiptOptions = {
order: OrderListItem;
paperSize: PaperSize;
storeName: string;
storeAddress: string;
};
async function printOrderReceipt(options: PrintOrderReceiptOptions): Promise<void> {
const { order, paperSize, storeName, storeAddress } = options;
let connection = connectionState.value;
if (!connection) {
const restored = await restoreConnection();
connection = connectionState.value;
if (!restored || !connection) {
throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
}
}
const bluetoothSettings = connection === 'bluetooth'
? getBluetoothEncoderSettings()
: null;
const encoderOptions = normalizeEncoderOptions({
language: bluetoothSettings?.language,
codepageMapping: bluetoothSettings?.codepageMapping,
});
const encoded = encodeOrderReceipt(order, {
storeName,
storeAddress,
paperSize,
language: connection === 'bluetooth' ? encoderOptions.language : undefined,
codepageMapping: connection === 'bluetooth' ? encoderOptions.codepageMapping : undefined,
});
await sendPrintData(encoded);
}
export function useThermalPrinter() {
const isConnected = computed(() => connectionState.value !== null);
const connectionLabel = computed(() => {
const connection = connectionState.value;
if (connection === 'usb') {
return 'USB';
}
if (connection === 'bluetooth') {
return 'Bluetooth';
}
return null;
});
return {
activeConnection: connectionState,
isConnected,
connectionLabel,
connecting,
connectUsb: () => withConnecting(connectUsb),
connectBluetooth: () => withConnecting(connectBluetooth),
tryReconnect: () => withConnecting(tryReconnectTransport),
disconnect: disconnectTransport,
printOrderReceipt,
supportsUsb: isWebSerialSupported(),
supportsBluetooth: isWebBluetoothSupported(),
};
}
export function getPaperSizeLabel(paperSize: PaperSize): string {
return paperSize;
}

View File

@ -0,0 +1,134 @@
import ReceiptPrinterEncoder from '@point-of-sale/receipt-printer-encoder';
import { normalizeEncoderOptions } from '@/lib/thermal-printer/normalize-encoder-options';
import type { PaperSize } from '@/lib/thermal-printer/types';
import type { OrderListItem } from '@/types/order';
export type EncodeOrderReceiptOptions = {
storeName: string;
storeAddress: string;
paperSize: PaperSize;
language?: string | null;
codepageMapping?: string | null;
};
export function getPaperColumns(paperSize: PaperSize): number {
return paperSize === '58mm' ? 32 : 48;
}
function dashedLine(columns: number): string {
return '-'.repeat(columns);
}
export function encodeOrderReceipt(
order: OrderListItem,
options: EncodeOrderReceiptOptions,
): Uint8Array {
const columns = getPaperColumns(options.paperSize);
const amountColumnWidth = 14;
const labelColumnWidth = columns - amountColumnWidth;
const encoderOptions = normalizeEncoderOptions({
language: options.language,
codepageMapping: options.codepageMapping,
});
const encoder = new ReceiptPrinterEncoder({
language: encoderOptions.language,
codepageMapping: encoderOptions.codepageMapping,
columns,
});
const createdBy = order.created_by?.profile?.full_name
?? order.created_by?.username
?? '-';
const isMarketplace = order.channel !== 'store';
encoder.initialize()
.align('center')
.bold(true)
.text('')
.newline()
.text(options.storeName)
.newline()
.bold(false)
.newline();
for (const addressLine of options.storeAddress.split('\n')) {
const trimmed = addressLine.trim();
if (trimmed) {
encoder.align('center').text(trimmed).newline();
}
}
encoder.align('center').line(dashedLine(columns));
encoder.align('left');
encoder.line(`No : ${order.order_number}`);
encoder.line(`Tgl : ${order.created_at_formatted}`);
if (order.customer) {
encoder.line(`Cust : ${order.customer.name}`);
}
encoder
.line(`Kasir: ${createdBy}`)
.line(dashedLine(columns));
for (const item of order.items) {
const productName = item.product_variant?.product?.name ?? '-';
const variantName = item.product_variant?.name ?? '-';
encoder
.line(productName)
.line(` ${variantName}`)
.table(
[
{ width: labelColumnWidth, align: 'left' },
{ width: amountColumnWidth, align: 'right' },
],
[[
`${item.quantity_formatted} x ${item.unit_price_formatted}`,
(rowEncoder) => rowEncoder.bold().text(item.subtotal_formatted).bold(false),
]],
)
.newline();
}
encoder.line(dashedLine(columns));
const summaryRows: string[][] = [
['Subtotal', order.subtotal_formatted],
['Diskon', order.discount_formatted],
];
if (isMarketplace) {
summaryRows.push(['Biaya MP', order.marketplace_fee_formatted]);
}
summaryRows.push(['Total', order.net_amount_formatted]);
encoder.table(
[
{ width: labelColumnWidth, align: 'left' },
{ width: amountColumnWidth, align: 'right' },
],
summaryRows,
);
if (order.notes) {
encoder
.newline()
.line('Catatan:')
.line(order.notes);
}
encoder
.newline()
.line(dashedLine(columns))
.align('center')
.line('Terima kasih atas kunjungannya')
.line('Silakan berbelanja kembali')
.newline(3)
.cut();
return encoder.encode();
}

View File

@ -0,0 +1,64 @@
export type EncoderLanguage = 'esc-pos' | 'star-prnt' | 'star-line';
const ESC_POS_CODEPAGE_MAPPINGS = new Set([
'bixolon/legacy',
'bixolon',
'citizen',
'epson/legacy',
'epson',
'fujitsu',
'hp',
'metapace',
'mpt',
'pos-5890',
'pos-8360',
'star',
'xprinter',
'youku',
'zijang',
]);
const STAR_CODEPAGE_MAPPINGS = new Set(['star']);
const CODEPAGE_ALIASES: Record<string, string> = {
default: 'epson',
zjiang: 'zijang',
};
export function normalizeEncoderLanguage(language?: string | null): EncoderLanguage {
if (language === 'star-prnt' || language === 'star-line') {
return language;
}
return 'esc-pos';
}
export function normalizeCodepageMapping(
language: EncoderLanguage,
codepageMapping?: string | null,
): string {
const mapping = codepageMapping
? CODEPAGE_ALIASES[codepageMapping] ?? codepageMapping
: 'epson';
if (language === 'star-prnt' || language === 'star-line') {
return STAR_CODEPAGE_MAPPINGS.has(mapping) ? mapping : 'star';
}
return ESC_POS_CODEPAGE_MAPPINGS.has(mapping) ? mapping : 'epson';
}
export function normalizeEncoderOptions(options?: {
language?: string | null;
codepageMapping?: string | null;
}): {
language: EncoderLanguage;
codepageMapping: string;
} {
const language = normalizeEncoderLanguage(options?.language);
return {
language,
codepageMapping: normalizeCodepageMapping(language, options?.codepageMapping),
};
}

View File

@ -0,0 +1,565 @@
import { ref } from 'vue';
import { normalizeEncoderOptions } from '@/lib/thermal-printer/normalize-encoder-options';
import type { PrinterConnection, StoredBluetoothDevice, StoredUsbDevice } from '@/lib/thermal-printer/types';
const USB_STORAGE_KEY = 'thermal-printer.usb-device';
const BLUETOOTH_STORAGE_KEY = 'thermal-printer.bluetooth-device';
const CONNECTION_STORAGE_KEY = 'thermal-printer.active-connection';
const USB_CHUNK_SIZE = 256;
const USB_CHUNK_DELAY_MS = 15;
const USB_FLUSH_DELAY_MS = 200;
type BleProfile = {
service: string;
characteristic: string;
language: string;
codepageMapping: string;
chunkSize: number;
chunkDelayMs: number;
};
const BLE_PROFILES: BleProfile[] = [
{
service: '000018f0-0000-1000-8000-00805f9b34fb',
characteristic: '00002af1-0000-1000-8000-00805f9b34fb',
language: 'esc-pos',
codepageMapping: 'xprinter',
chunkSize: 50,
chunkDelayMs: 35,
},
{
service: '49535343-fe7d-4ae5-8fa9-9fafd205e455',
characteristic: '49535343-8841-43f4-a8d4-ecbe34729bb3',
language: 'esc-pos',
codepageMapping: 'epson',
chunkSize: 100,
chunkDelayMs: 25,
},
{
service: '0000ae30-0000-1000-8000-00805f9b34fb',
characteristic: '0000ae01-0000-1000-8000-00805f9b34fb',
language: 'esc-pos',
codepageMapping: 'epson',
chunkSize: 100,
chunkDelayMs: 35,
},
];
export const connectionState = ref<PrinterConnection | null>(null);
let activeConnection: PrinterConnection | null = null;
let usbPort: SerialPort | null = null;
let bluetoothDevice: BluetoothDevice | null = null;
let bluetoothCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
let activeBleProfile: BleProfile | null = null;
let bluetoothEncoderSettings: StoredBluetoothDevice | null = null;
let printQueue: Promise<void> = Promise.resolve();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function readStoredConnection(): PrinterConnection | null {
try {
const raw = localStorage.getItem(CONNECTION_STORAGE_KEY);
if (raw === 'usb' || raw === 'bluetooth') {
return raw;
}
} catch {
return null;
}
return null;
}
function storeActiveConnection(connection: PrinterConnection | null): void {
if (connection) {
localStorage.setItem(CONNECTION_STORAGE_KEY, connection);
} else {
localStorage.removeItem(CONNECTION_STORAGE_KEY);
}
}
function readStoredUsbDevice(): StoredUsbDevice | null {
try {
const raw = localStorage.getItem(USB_STORAGE_KEY);
if (!raw) {
return null;
}
return JSON.parse(raw) as StoredUsbDevice;
} catch {
return null;
}
}
function storeUsbDevice(device: StoredUsbDevice): void {
localStorage.setItem(USB_STORAGE_KEY, JSON.stringify(device));
}
function readStoredBluetoothDevice(): StoredBluetoothDevice | null {
try {
const raw = localStorage.getItem(BLUETOOTH_STORAGE_KEY);
if (!raw) {
return null;
}
return JSON.parse(raw) as StoredBluetoothDevice;
} catch {
return null;
}
}
function storeBluetoothDevice(device: StoredBluetoothDevice): void {
localStorage.setItem(BLUETOOTH_STORAGE_KEY, JSON.stringify(device));
}
function clearUsbState(): void {
usbPort = null;
}
function clearBluetoothState(): void {
bluetoothDevice = null;
bluetoothCharacteristic = null;
activeBleProfile = null;
}
function setActiveConnection(connection: PrinterConnection | null): void {
activeConnection = connection;
connectionState.value = connection;
storeActiveConnection(connection);
}
function markDisconnected(): void {
setActiveConnection(null);
}
function bindUsbDisconnectHandler(port: SerialPort): void {
port.addEventListener('disconnect', () => {
if (activeConnection === 'usb') {
clearUsbState();
markDisconnected();
}
});
}
async function openUsbPort(port: SerialPort): Promise<void> {
if (!port.readable && !port.writable) {
await port.open({
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
bufferSize: 255,
});
}
const info = port.getInfo();
usbPort = port;
setActiveConnection('usb');
bindUsbDisconnectHandler(port);
if (info.usbVendorId && info.usbProductId) {
storeUsbDevice({
vendorId: info.usbVendorId,
productId: info.usbProductId,
});
}
}
async function resolveBluetoothPrintChannel(device: BluetoothDevice): Promise<{
characteristic: BluetoothRemoteGATTCharacteristic;
profile: BleProfile;
}> {
const server = device.gatt;
if (!server) {
throw new Error('Printer Bluetooth tidak mendukung GATT.');
}
if (!server.connected) {
await server.connect();
}
const services = await server.getPrimaryServices().catch(() => []);
const serviceIds = new Set(services.map((service) => service.uuid));
for (const profile of BLE_PROFILES) {
if (!serviceIds.has(profile.service)) {
continue;
}
try {
const service = await server.getPrimaryService(profile.service);
const characteristic = await service.getCharacteristic(profile.characteristic);
return { characteristic, profile };
} catch {
continue;
}
}
for (const profile of BLE_PROFILES) {
try {
const service = await server.getPrimaryService(profile.service);
const characteristic = await service.getCharacteristic(profile.characteristic);
return { characteristic, profile };
} catch {
continue;
}
}
throw new Error('Service cetak printer Bluetooth tidak ditemukan.');
}
async function attachBluetoothDevice(device: BluetoothDevice): Promise<void> {
const { characteristic, profile } = await resolveBluetoothPrintChannel(device);
const encoderOptions = normalizeEncoderOptions({
language: profile.language,
codepageMapping: profile.codepageMapping,
});
bluetoothDevice = device;
bluetoothCharacteristic = characteristic;
activeBleProfile = profile;
bluetoothEncoderSettings = {
id: device.id,
language: encoderOptions.language,
codepageMapping: encoderOptions.codepageMapping,
};
setActiveConnection('bluetooth');
storeBluetoothDevice(bluetoothEncoderSettings);
device.addEventListener('gattserverdisconnected', () => {
if (activeConnection === 'bluetooth') {
clearBluetoothState();
markDisconnected();
}
});
}
async function writeBluetoothChunk(
characteristic: BluetoothRemoteGATTCharacteristic,
chunk: Uint8Array,
): Promise<void> {
try {
await characteristic.writeValueWithResponse(chunk);
} catch {
await characteristic.writeValueWithoutResponse(chunk);
await sleep(20);
}
}
async function sendUsbData(data: Uint8Array): Promise<void> {
if (!usbPort?.writable) {
throw new Error('Port USB printer tidak siap.');
}
const writer = usbPort.writable.getWriter();
try {
for (let offset = 0; offset < data.length; offset += USB_CHUNK_SIZE) {
const chunk = data.slice(offset, offset + USB_CHUNK_SIZE);
await writer.write(chunk);
if (offset + USB_CHUNK_SIZE < data.length) {
await sleep(USB_CHUNK_DELAY_MS);
}
}
await writer.ready;
} finally {
writer.releaseLock();
}
await sleep(USB_FLUSH_DELAY_MS);
}
async function sendBluetoothData(data: Uint8Array): Promise<void> {
if (!bluetoothCharacteristic || !activeBleProfile) {
throw new Error('Printer Bluetooth belum siap.');
}
const { chunkSize, chunkDelayMs } = activeBleProfile;
for (let offset = 0; offset < data.length; offset += chunkSize) {
const chunk = data.slice(offset, offset + chunkSize);
await writeBluetoothChunk(bluetoothCharacteristic, chunk);
if (offset + chunkSize < data.length) {
await sleep(chunkDelayMs);
}
}
await sleep(120);
}
async function reconnectUsb(): Promise<void> {
const stored = readStoredUsbDevice();
const ports = await navigator.serial!.getPorts();
if (!ports.length) {
throw new Error('Printer USB tidak ditemukan. Hubungkan ulang secara manual.');
}
const orderedPorts = [
...ports.filter((candidate) => {
const info = candidate.getInfo();
return stored?.vendorId === info.usbVendorId
&& stored?.productId === info.usbProductId;
}),
...ports.filter((candidate) => {
const info = candidate.getInfo();
return stored?.vendorId !== info.usbVendorId
|| stored?.productId !== info.usbProductId;
}),
];
clearUsbState();
let lastError: Error | null = null;
for (const port of orderedPorts) {
try {
await openUsbPort(port);
return;
} catch (error) {
lastError = error instanceof Error
? error
: new Error('Gagal membuka port USB printer.');
}
}
throw lastError ?? new Error('Printer USB tidak ditemukan. Hubungkan ulang secara manual.');
}
async function reconnectBluetooth(): Promise<void> {
const stored = readStoredBluetoothDevice();
if (!stored?.id || !navigator.bluetooth?.getDevices) {
throw new Error('Printer Bluetooth tidak ditemukan. Hubungkan ulang secara manual.');
}
const devices = await navigator.bluetooth.getDevices();
const device = devices.find((candidate) => candidate.id === stored.id);
if (!device) {
throw new Error('Printer Bluetooth tidak ditemukan. Hubungkan ulang secara manual.');
}
clearBluetoothState();
await attachBluetoothDevice(device);
}
async function restoreStoredConnection(): Promise<boolean> {
const storedConnection = readStoredConnection();
if (!storedConnection) {
return false;
}
try {
if (storedConnection === 'usb' && isWebSerialSupported()) {
await reconnectUsb();
return activeConnection === 'usb';
}
if (storedConnection === 'bluetooth' && isWebBluetoothSupported()) {
await reconnectBluetooth();
return activeConnection === 'bluetooth';
}
} catch {
clearUsbState();
clearBluetoothState();
}
return false;
}
async function ensureReady(): Promise<void> {
if (!activeConnection) {
const restored = await restoreStoredConnection();
if (!restored) {
throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
}
}
if (activeConnection === 'usb') {
if (usbPort?.writable) {
return;
}
await reconnectUsb();
return;
}
if (activeConnection === 'bluetooth') {
if (bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) {
return;
}
await reconnectBluetooth();
}
}
async function forceReconnect(): Promise<void> {
const connection = activeConnection ?? readStoredConnection();
if (connection === 'usb') {
await reconnectUsb();
return;
}
if (connection === 'bluetooth') {
await reconnectBluetooth();
}
}
async function sendPrintDataInternal(data: Uint8Array): Promise<void> {
if (!activeConnection) {
throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
}
await ensureReady();
if (activeConnection === 'usb') {
await sendUsbData(data);
return;
}
if (activeConnection === 'bluetooth') {
await sendBluetoothData(data);
return;
}
throw new Error('Koneksi printer tidak dikenali.');
}
export function getActiveConnection(): PrinterConnection | null {
return activeConnection;
}
export function getBluetoothEncoderSettings(): StoredBluetoothDevice | null {
return bluetoothEncoderSettings ?? readStoredBluetoothDevice();
}
export function isWebSerialSupported(): boolean {
return typeof navigator !== 'undefined' && 'serial' in navigator;
}
export function isWebBluetoothSupported(): boolean {
return typeof navigator !== 'undefined' && 'bluetooth' in navigator;
}
export async function connectUsb(): Promise<void> {
if (!isWebSerialSupported()) {
throw new Error('Browser tidak mendukung Web Serial. Gunakan Chrome/Edge dan sambungkan printer via USB.');
}
const port = await navigator.serial!.requestPort();
clearUsbState();
await openUsbPort(port);
}
export async function connectBluetooth(): Promise<void> {
if (!isWebBluetoothSupported()) {
throw new Error('Browser tidak mendukung Web Bluetooth. Gunakan Chrome/Edge dan aktifkan Bluetooth.');
}
const device = await navigator.bluetooth!.requestDevice({
acceptAllDevices: true,
optionalServices: BLE_PROFILES.map((profile) => profile.service),
});
clearBluetoothState();
await attachBluetoothDevice(device);
}
export async function restoreConnection(): Promise<boolean> {
if (activeConnection === 'usb' && usbPort?.writable) {
return true;
}
if (activeConnection === 'bluetooth' && bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) {
return true;
}
return restoreStoredConnection();
}
export async function tryReconnect(): Promise<boolean> {
return restoreConnection();
}
export function disconnect(): void {
if (usbPort) {
void usbPort.close().catch(() => undefined);
}
if (bluetoothDevice?.gatt?.connected) {
bluetoothDevice.gatt.disconnect();
}
clearUsbState();
clearBluetoothState();
markDisconnected();
}
export async function sendPrintData(data: Uint8Array, maxAttempts = 3): Promise<void> {
const task = printQueue
.catch(() => undefined)
.then(async () => {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await sendPrintDataInternal(data);
return;
} catch (error) {
lastError = error instanceof Error
? error
: new Error('Gagal mengirim data ke printer.');
clearUsbState();
clearBluetoothState();
if (attempt < maxAttempts) {
await sleep(400);
try {
await forceReconnect();
} catch {
// Retry will surface the final error.
}
}
}
}
throw lastError ?? new Error('Gagal mencetak struk.');
});
printQueue = task.catch(() => undefined);
await task;
}

View File

@ -0,0 +1,16 @@
export type PaperSize = '58mm' | '80mm';
export type PrinterConnection = 'usb' | 'bluetooth';
export type PrinterLanguage = 'esc-pos' | 'star-prnt' | 'star-line';
export type StoredUsbDevice = {
vendorId: number | null;
productId: number | null;
};
export type StoredBluetoothDevice = {
id: string;
language: PrinterLanguage;
codepageMapping: string;
};

View File

@ -3,6 +3,7 @@ import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import OrderGroupedTable from '@/components/admin/manage/orders/OrderGroupedTable.vue';
import ThermalPrinterConnectButton from '@/components/admin/manage/orders/ThermalPrinterConnectButton.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
@ -65,12 +66,16 @@ watch(
</h2>
</div>
<Button v-if="can('orders.create')" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/manage/orders/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
<ThermalPrinterConnectButton />
<Button v-if="can('orders.create')" as-child>
<Link href="/admin/manage/orders/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
</div>
</div>
<Card class="min-w-0">

View File

@ -17,6 +17,7 @@ declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: {
name: string;
address: string | null;
auth: Auth;
flash: {
success: string | null;

61
resources/js/types/point-of-sale.d.ts vendored Normal file
View File

@ -0,0 +1,61 @@
declare module '@point-of-sale/receipt-printer-encoder' {
export default class ReceiptPrinterEncoder {
constructor(options?: Record<string, unknown>);
initialize(): this;
align(alignment: 'left' | 'center' | 'right'): this;
bold(value?: boolean): this;
line(text: string): this;
text(text: string): this;
newline(count?: number): this;
rule(options?: { style?: string; width?: number }): this;
table(
columns: Array<Record<string, unknown>>,
rows: Array<Array<string | ((encoder: ReceiptPrinterEncoder) => ReceiptPrinterEncoder)>>,
): this;
cut(): this;
encode(): Uint8Array;
}
}
declare module '@point-of-sale/webserial-receipt-printer' {
export type SerialPrinterDevice = {
type: 'serial';
vendorId: number | null;
productId: number | null;
language: string | null;
codepageMapping: string | null;
};
export default class WebSerialReceiptPrinter {
constructor(options?: Record<string, unknown>);
connect(): Promise<void>;
reconnect(device: { vendorId?: number | null; productId?: number | null }): Promise<void>;
print(data: Uint8Array | number[]): Promise<void>;
addEventListener(
event: 'connected' | 'disconnected' | 'data',
callback: (device?: SerialPrinterDevice) => void,
): void;
}
}
declare module '@point-of-sale/webbluetooth-receipt-printer' {
export type BluetoothPrinterDevice = {
type: 'bluetooth';
name: string;
id: string;
language: 'esc-pos' | 'star-prnt';
codepageMapping: string;
};
export default class WebBluetoothReceiptPrinter {
connect(): Promise<void>;
reconnect(device: { id: string }): Promise<void>;
print(data: Uint8Array | number[]): Promise<void>;
addEventListener(
event: 'connected' | 'disconnected',
callback: (device?: BluetoothPrinterDevice) => void,
): void;
}
}

View File

@ -0,0 +1,57 @@
interface BluetoothRemoteGATTServer {
readonly connected: boolean;
connect(): Promise<BluetoothRemoteGATTServer>;
disconnect(): void;
getPrimaryService(service: string): Promise<BluetoothRemoteGATTService>;
getPrimaryServices(): Promise<BluetoothRemoteGATTService[]>;
}
interface BluetoothRemoteGATTService {
readonly uuid: string;
getCharacteristic(characteristic: string): Promise<BluetoothRemoteGATTCharacteristic>;
}
interface BluetoothRemoteGATTCharacteristic {
writeValueWithResponse(data: BufferSource): Promise<void>;
writeValueWithoutResponse(data: BufferSource): Promise<void>;
}
interface BluetoothDevice extends EventTarget {
readonly id: string;
readonly name?: string;
readonly gatt?: BluetoothRemoteGATTServer;
}
interface Bluetooth extends EventTarget {
requestDevice(options: {
acceptAllDevices?: boolean;
filters?: Array<Record<string, unknown>>;
optionalServices?: string[];
}): Promise<BluetoothDevice>;
getDevices(): Promise<BluetoothDevice[]>;
}
interface SerialPort extends EventTarget {
readable: ReadableStream<Uint8Array> | null;
writable: WritableStream<Uint8Array> | null;
open(options: {
baudRate: number;
dataBits?: number;
stopBits?: number;
parity?: string;
flowControl?: string;
bufferSize?: number;
}): Promise<void>;
close(): Promise<void>;
getInfo(): { usbVendorId?: number; usbProductId?: number };
}
interface Serial extends EventTarget {
requestPort(): Promise<SerialPort>;
getPorts(): Promise<SerialPort[]>;
}
interface Navigator {
serial?: Serial;
bluetooth?: Bluetooth;
}