diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php
index 570ed4e..4b19b82 100644
--- a/app/Enums/OrderStatus.php
+++ b/app/Enums/OrderStatus.php
@@ -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,
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index b6674f7..c17e7f6 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -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,
diff --git a/package-lock.json b/package-lock.json
index 1a94fe3..60ce9b8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 45aadc4..177df18 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/resources/js/app.ts b/resources/js/app.ts
index b971d56..47abc97 100644
--- a/resources/js/app.ts
+++ b/resources/js/app.ts
@@ -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';
diff --git a/resources/js/components/admin/manage/orders/OrderGroupedTable.vue b/resources/js/components/admin/manage/orders/OrderGroupedTable.vue
index 3cf630b..72b0485 100644
--- a/resources/js/components/admin/manage/orders/OrderGroupedTable.vue
+++ b/resources/js/components/admin/manage/orders/OrderGroupedTable.vue
@@ -103,9 +103,6 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
{{ order.customer.name }}
-
- Tanpa pelanggan
-
{{ order.created_at_formatted }}
Oleh {{ order.created_by?.profile?.full_name ?? order.created_by?.username }}
diff --git a/resources/js/components/admin/manage/orders/OrderPrintButton.vue b/resources/js/components/admin/manage/orders/OrderPrintButton.vue
new file mode 100644
index 0000000..64b673d
--- /dev/null
+++ b/resources/js/components/admin/manage/orders/OrderPrintButton.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+ Ukuran kertas
+
+
+
+
+
+
+
diff --git a/resources/js/components/admin/manage/orders/ThermalPrinterConnectButton.vue b/resources/js/components/admin/manage/orders/ThermalPrinterConnectButton.vue
new file mode 100644
index 0000000..a578f2c
--- /dev/null
+++ b/resources/js/components/admin/manage/orders/ThermalPrinterConnectButton.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+ Koneksi Printer
+
+
+ Hubungkan printer thermal sebelum mencetak struk.
+
+
+
+
+ Terhubung via {{ connectionLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/admin/manage/orders/data-table-actions.vue b/resources/js/components/admin/manage/orders/data-table-actions.vue
index 7446f31..0f6e7a1 100644
--- a/resources/js/components/admin/manage/orders/data-table-actions.vue
+++ b/resources/js/components/admin/manage/orders/data-table-actions.vue
@@ -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) {
+
+
diff --git a/resources/js/composables/useThermalPrinter.ts b/resources/js/composables/useThermalPrinter.ts
new file mode 100644
index 0000000..ece4581
--- /dev/null
+++ b/resources/js/composables/useThermalPrinter.ts
@@ -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(callback: () => Promise): Promise {
+ 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 {
+ 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;
+}
diff --git a/resources/js/lib/thermal-printer/encode-order-receipt.ts b/resources/js/lib/thermal-printer/encode-order-receipt.ts
new file mode 100644
index 0000000..e73d3f1
--- /dev/null
+++ b/resources/js/lib/thermal-printer/encode-order-receipt.ts
@@ -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();
+}
diff --git a/resources/js/lib/thermal-printer/normalize-encoder-options.ts b/resources/js/lib/thermal-printer/normalize-encoder-options.ts
new file mode 100644
index 0000000..742d221
--- /dev/null
+++ b/resources/js/lib/thermal-printer/normalize-encoder-options.ts
@@ -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 = {
+ 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),
+ };
+}
diff --git a/resources/js/lib/thermal-printer/stable-transport.ts b/resources/js/lib/thermal-printer/stable-transport.ts
new file mode 100644
index 0000000..df44b73
--- /dev/null
+++ b/resources/js/lib/thermal-printer/stable-transport.ts
@@ -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(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 = Promise.resolve();
+
+function sleep(ms: number): Promise {
+ 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 {
+ 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 {
+ 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 {
+ try {
+ await characteristic.writeValueWithResponse(chunk);
+ } catch {
+ await characteristic.writeValueWithoutResponse(chunk);
+ await sleep(20);
+ }
+}
+
+async function sendUsbData(data: Uint8Array): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const connection = activeConnection ?? readStoredConnection();
+
+ if (connection === 'usb') {
+ await reconnectUsb();
+
+ return;
+ }
+
+ if (connection === 'bluetooth') {
+ await reconnectBluetooth();
+ }
+}
+
+async function sendPrintDataInternal(data: Uint8Array): Promise {
+ 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 {
+ 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 {
+ 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 {
+ if (activeConnection === 'usb' && usbPort?.writable) {
+ return true;
+ }
+
+ if (activeConnection === 'bluetooth' && bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) {
+ return true;
+ }
+
+ return restoreStoredConnection();
+}
+
+export async function tryReconnect(): Promise {
+ 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 {
+ 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;
+}
diff --git a/resources/js/lib/thermal-printer/types.ts b/resources/js/lib/thermal-printer/types.ts
new file mode 100644
index 0000000..b2b2ab5
--- /dev/null
+++ b/resources/js/lib/thermal-printer/types.ts
@@ -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;
+};
diff --git a/resources/js/pages/admin/manage/orders/Index.vue b/resources/js/pages/admin/manage/orders/Index.vue
index 799d1be..fd710cb 100644
--- a/resources/js/pages/admin/manage/orders/Index.vue
+++ b/resources/js/pages/admin/manage/orders/Index.vue
@@ -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(
-
+
diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts
index 25fe199..3106cb2 100644
--- a/resources/js/types/global.d.ts
+++ b/resources/js/types/global.d.ts
@@ -17,6 +17,7 @@ declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: {
name: string;
+ address: string | null;
auth: Auth;
flash: {
success: string | null;
diff --git a/resources/js/types/point-of-sale.d.ts b/resources/js/types/point-of-sale.d.ts
new file mode 100644
index 0000000..f76ebac
--- /dev/null
+++ b/resources/js/types/point-of-sale.d.ts
@@ -0,0 +1,61 @@
+declare module '@point-of-sale/receipt-printer-encoder' {
+ export default class ReceiptPrinterEncoder {
+ constructor(options?: Record);
+
+ 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>,
+ rows: Array 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);
+
+ connect(): Promise;
+ reconnect(device: { vendorId?: number | null; productId?: number | null }): Promise;
+ print(data: Uint8Array | number[]): Promise;
+ 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;
+ reconnect(device: { id: string }): Promise;
+ print(data: Uint8Array | number[]): Promise;
+ addEventListener(
+ event: 'connected' | 'disconnected',
+ callback: (device?: BluetoothPrinterDevice) => void,
+ ): void;
+ }
+}
diff --git a/resources/js/types/thermal-bluetooth.d.ts b/resources/js/types/thermal-bluetooth.d.ts
new file mode 100644
index 0000000..9c58561
--- /dev/null
+++ b/resources/js/types/thermal-bluetooth.d.ts
@@ -0,0 +1,57 @@
+interface BluetoothRemoteGATTServer {
+ readonly connected: boolean;
+ connect(): Promise;
+ disconnect(): void;
+ getPrimaryService(service: string): Promise;
+ getPrimaryServices(): Promise;
+}
+
+interface BluetoothRemoteGATTService {
+ readonly uuid: string;
+ getCharacteristic(characteristic: string): Promise;
+}
+
+interface BluetoothRemoteGATTCharacteristic {
+ writeValueWithResponse(data: BufferSource): Promise;
+ writeValueWithoutResponse(data: BufferSource): Promise;
+}
+
+interface BluetoothDevice extends EventTarget {
+ readonly id: string;
+ readonly name?: string;
+ readonly gatt?: BluetoothRemoteGATTServer;
+}
+
+interface Bluetooth extends EventTarget {
+ requestDevice(options: {
+ acceptAllDevices?: boolean;
+ filters?: Array>;
+ optionalServices?: string[];
+ }): Promise;
+ getDevices(): Promise;
+}
+
+interface SerialPort extends EventTarget {
+ readable: ReadableStream | null;
+ writable: WritableStream | null;
+ open(options: {
+ baudRate: number;
+ dataBits?: number;
+ stopBits?: number;
+ parity?: string;
+ flowControl?: string;
+ bufferSize?: number;
+ }): Promise;
+ close(): Promise;
+ getInfo(): { usbVendorId?: number; usbProductId?: number };
+}
+
+interface Serial extends EventTarget {
+ requestPort(): Promise;
+ getPorts(): Promise;
+}
+
+interface Navigator {
+ serial?: Serial;
+ bluetooth?: Bluetooth;
+}