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:
parent
5233890030
commit
93992a0d3a
@ -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,
|
||||
|
||||
@ -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
59
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
104
resources/js/composables/useThermalPrinter.ts
Normal file
104
resources/js/composables/useThermalPrinter.ts
Normal 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;
|
||||
}
|
||||
134
resources/js/lib/thermal-printer/encode-order-receipt.ts
Normal file
134
resources/js/lib/thermal-printer/encode-order-receipt.ts
Normal 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();
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
565
resources/js/lib/thermal-printer/stable-transport.ts
Normal file
565
resources/js/lib/thermal-printer/stable-transport.ts
Normal 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;
|
||||
}
|
||||
16
resources/js/lib/thermal-printer/types.ts
Normal file
16
resources/js/lib/thermal-printer/types.ts
Normal 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;
|
||||
};
|
||||
@ -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">
|
||||
|
||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@ -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
61
resources/js/types/point-of-sale.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
57
resources/js/types/thermal-bluetooth.d.ts
vendored
Normal file
57
resources/js/types/thermal-bluetooth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user