From 08cb1a5d783e6742516761a7b1dd31e15373a44e Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 22 Apr 2026 20:00:11 +0700 Subject: [PATCH] feat: implement thermal printer support with ESC/POS encoding and device connectivity hooks --- resources/js/hooks/use-printer.ts | 205 ++++++++++++++++++ resources/js/lib/esc-pos-encoder.ts | 87 ++++++++ .../js/pages/admin/manage/order/index.tsx | 152 ++++++++++++- .../admin/manage/order/partials/columns.tsx | 17 +- 4 files changed, 451 insertions(+), 10 deletions(-) create mode 100644 resources/js/hooks/use-printer.ts create mode 100644 resources/js/lib/esc-pos-encoder.ts diff --git a/resources/js/hooks/use-printer.ts b/resources/js/hooks/use-printer.ts new file mode 100644 index 0000000..c4039ae --- /dev/null +++ b/resources/js/hooks/use-printer.ts @@ -0,0 +1,205 @@ +import { useState, useCallback } from 'react'; +import { toast } from 'sonner'; + +// Type definitions for Web Bluetooth and Web USB +// This is to satisfy TypeScript if the global types are missing +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type PrinterType = 'bluetooth' | 'usb' | null; +export type PaperSize = '58' | '80'; + +interface PrinterState { + device: any; // Using any to avoid complex type issues with experimental APIs + type: PrinterType; + isConnected: boolean; + name: string | null; + paperSize: PaperSize; +} + +export function usePrinter() { + const [state, setState] = useState(() => { + return { + device: null, + type: null, + isConnected: false, + name: null, + paperSize: '58', + }; + }); + + const [characteristic, setCharacteristic] = useState(null); + + const connectBluetooth = useCallback(async () => { + try { + const nav = navigator as any; + if (!nav.bluetooth) { + toast.error('Bluetooth tidak didukung di browser ini'); + return; + } + + const device = await nav.bluetooth.requestDevice({ + acceptAllDevices: true, + optionalServices: ['000018f0-0000-1000-8000-00805f9b34fb', '49535343-fe7d-41aa-83b1-d10935904914', '0000ff00-0000-1000-8000-00805f9b34fb'] + }); + + const server = await device.gatt?.connect(); + const services = await server.getPrimaryServices(); + + const writeChar = await findWriteCharacteristic(services); + + if (!writeChar) { + throw new Error('Tidak dapat menemukan characteristic untuk menulis data'); + } + + setCharacteristic(writeChar); + setState(prev => ({ + ...prev, + device, + type: 'bluetooth', + isConnected: true, + name: device.name || 'Bluetooth Printer', + })); + + toast.success(`Terhubung ke ${device.name || 'Printer Bluetooth'}`); + + device.addEventListener('gattserverdisconnected', () => { + setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); + setCharacteristic(null); + toast.error('Printer Bluetooth terputus'); + }); + + } catch (error: any) { + console.error('Bluetooth connection error:', error); + if (error.name !== 'NotFoundError') { + toast.error(error.message || 'Gagal menghubungkan printer bluetooth'); + } + } + }, []); + + const connectUsb = useCallback(async () => { + try { + const nav = navigator as any; + if (!nav.usb) { + toast.error('USB tidak didukung di browser ini'); + return; + } + + const device = await nav.usb.requestDevice({ + filters: [] + }); + + await device.open(); + if (device.configuration === null) { + await device.selectConfiguration(1); + } + + const iface = device.configuration.interfaces[0]; + await device.claimInterface(iface.interfaceNumber); + + setState(prev => ({ + ...prev, + device, + type: 'usb', + isConnected: true, + name: device.productName || 'USB Printer', + })); + + toast.success(`Terhubung ke ${device.productName || 'Printer USB'}`); + + } catch (error: any) { + console.error('USB connection error:', error); + if (error.name !== 'NotFoundError') { + toast.error(error.message || 'Gagal menghubungkan printer USB'); + } + } + }, []); + + const setPaperSize = useCallback((size: PaperSize) => { + setState(prev => ({ ...prev, paperSize: size })); + }, []); + + const sendData = useCallback(async (data: Uint8Array) => { + if (!state.isConnected || !state.device) { + toast.error('Printer belum terhubung'); + return; + } + + try { + if (state.type === 'bluetooth') { + const device = state.device; + + // Try to reconnect if GATT is disconnected + if (!device.gatt?.connected) { + toast.loading('Menghubungkan kembali...', { id: 'printer-reconnect' }); + const server = await device.gatt?.connect(); + const services = await server.getPrimaryServices(); + const writeChar = await findWriteCharacteristic(services); + if (writeChar) { + setCharacteristic(writeChar); + toast.success('Printer terhubung kembali', { id: 'printer-reconnect' }); + } else { + toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' }); + return; + } + } + + if (!characteristic) throw new Error('Characteristic tidak ditemukan'); + + const chunkSize = 20; + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.slice(i, i + chunkSize); + // Use writeValueWithoutResponse for better stability if supported + if (characteristic.writeValueWithoutResponse) { + await characteristic.writeValueWithoutResponse(chunk); + } else { + await characteristic.writeValue(chunk); + } + } + } else if (state.type === 'usb') { + const device = state.device as any; + const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk'); + if (!endpoint) throw new Error('Tidak dapat menemukan USB endpoint'); + + await device.transferOut(endpoint.endpointNumber, data); + } + } catch (error: any) { + console.error('Print error:', error); + toast.error('Gagal mengirim data ke printer: ' + (error.message || 'Error tidak diketahui')); + } + }, [state.isConnected, state.device, state.type, characteristic]); + + const disconnect = useCallback(async () => { + if (state.type === 'bluetooth' && state.device) { + if (state.device.gatt?.connected) { + state.device.gatt.disconnect(); + } + } else if (state.type === 'usb' && state.device) { + await state.device.close(); + } + + setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); + setCharacteristic(null); + toast.info('Printer terputus'); + }, [state.device, state.type]); + + return { + ...state, + connectBluetooth, + connectUsb, + disconnect, + sendData, + setPaperSize, + }; +} + +async function findWriteCharacteristic(services: any[]) { + for (const service of services) { + const characteristics = await service.getCharacteristics(); + for (const char of characteristics) { + if (char.properties.write || char.properties.writeWithoutResponse) { + return char; + } + } + } + return null; +} diff --git a/resources/js/lib/esc-pos-encoder.ts b/resources/js/lib/esc-pos-encoder.ts new file mode 100644 index 0000000..10eeb19 --- /dev/null +++ b/resources/js/lib/esc-pos-encoder.ts @@ -0,0 +1,87 @@ +/** + * A simple ESC/POS encoder for thermal printers + */ +export class EscPosEncoder { + private encoder = new TextEncoder(); + private buffer: number[] = []; + + constructor() {} + + /** + * Initialize printer + */ + initialize(): this { + this.buffer.push(0x1b, 0x40); + return this; + } + + /** + * Write text + */ + text(value: string): this { + const bytes = this.encoder.encode(value); + this.buffer.push(...Array.from(bytes)); + return this; + } + + /** + * Write text with newline + */ + line(value: string = ''): this { + this.text(value + '\n'); + return this; + } + + /** + * Set alignment + * 0: left, 1: center, 2: right + */ + align(value: 0 | 1 | 2): this { + this.buffer.push(0x1b, 0x61, value); + return this; + } + + /** + * Set bold + */ + bold(value: boolean): this { + this.buffer.push(0x1b, 0x45, value ? 1 : 0); + return this; + } + + /** + * Set font size + * 0: normal, 1: double height, 2: double width, 3: double height + width + */ + size(value: 0 | 1 | 2 | 3): this { + let size = 0; + if (value === 1) size = 0x01; + if (value === 2) size = 0x10; + if (value === 3) size = 0x11; + this.buffer.push(0x1d, 0x21, size); + return this; + } + + /** + * Feed and cut + */ + cut(): this { + this.buffer.push(0x1d, 0x56, 0x00); + return this; + } + + /** + * Feed lines + */ + feed(lines: number = 1): this { + this.buffer.push(0x1b, 0x64, lines); + return this; + } + + /** + * Get the encoded bytes + */ + encode(): Uint8Array { + return new Uint8Array(this.buffer); + } +} diff --git a/resources/js/pages/admin/manage/order/index.tsx b/resources/js/pages/admin/manage/order/index.tsx index 14ec0e7..7aa2330 100644 --- a/resources/js/pages/admin/manage/order/index.tsx +++ b/resources/js/pages/admin/manage/order/index.tsx @@ -1,9 +1,23 @@ import { Head, Link } from '@inertiajs/react'; +import { useCallback } from 'react'; import type { Order } from '@/types/order'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Trash2, ShoppingBag } from 'lucide-react'; +import { Trash2, ShoppingBag, Printer, Bluetooth, Usb, Unplug } from 'lucide-react'; import { DataTable } from '@/components/data-table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} from "@/components/ui/dropdown-menu" import { AlertDialog, AlertDialogAction, @@ -19,6 +33,11 @@ import { import * as orderRoutes from '@/routes/order'; import { useOrderIndex } from './hooks/use-order-index'; import { getColumns } from './partials/columns'; +import { usePrinter } from '@/hooks/use-printer'; +import { EscPosEncoder } from '@/lib/esc-pos-encoder'; +import { format } from 'date-fns'; +import { id } from 'date-fns/locale'; +import { toast } from 'sonner'; export default function OrderIndex({ orders }: { orders: Order[] }) { const { @@ -36,7 +55,79 @@ export default function OrderIndex({ orders }: { orders: Order[] }) { confirmBulkDelete, } = useOrderIndex(); - const columns = getColumns({ onDelete }); + const { + isConnected, + name, + connectBluetooth, + connectUsb, + disconnect, + sendData, + paperSize, + setPaperSize + } = usePrinter(); + + const handlePrint = useCallback(async (order: Order) => { + if (!isConnected) { + toast.error('Hubungkan printer terlebih dahulu'); + return; + } + + const width = paperSize === '58' ? 32 : 48; + const line = '-'.repeat(width); + + const encoder = new EscPosEncoder(); + const result = encoder + .initialize() + .align(1) + .size(1) + .bold(true) + .line('VN Grup') + .size(0) + .bold(false) + .line(line) + .align(0) + .line(`No. Invoice : ${order.invoice_number}`) + .line(`Tanggal : ${format(new Date(order.created_at), 'dd MMMM yyyy HH:mm', { locale: id })}`) + .line(`Pelanggan : ${order.customer_name}`) + .line(line); + + order.items?.forEach((item, index) => { + if (index > 0) result.line(); + result.line(item.product?.name || 'Produk'); + const qtyPrice = `${item.qty} x ${item.price_formatted}`; + const subtotal = item.total_formatted; + const spaces = width - qtyPrice.length - subtotal.length; + result.line(qtyPrice + ' '.repeat(Math.max(0, spaces)) + subtotal); + }); + + result.line(line); + + const discountAmount = Number(order.discount || 0); + if (discountAmount > 0) { + const discountLabel = 'Diskon:'; + const discountVal = `- Rp ${discountAmount.toLocaleString('id-ID')}`; + const dSpaces = width - discountLabel.length - discountVal.length; + result.line(discountLabel + ' '.repeat(Math.max(0, dSpaces)) + discountVal); + } + + const totalLabel = 'TOTAL:'; + const totalVal = order.total_formatted; + const tSpaces = width - totalLabel.length - totalVal.length; + result.bold(true).line(totalLabel + ' '.repeat(Math.max(0, tSpaces)) + totalVal).bold(false); + + result + .line(line) + .align(1) + .feed(1) + .line('Terima Kasih') + .line('Selamat Belanja Kembali') + .feed(1) + .cut(); + + await sendData(result.encode()); + }, [isConnected, sendData, paperSize]); + + const columns = getColumns({ onDelete, onPrint: handlePrint }); return (
@@ -46,11 +137,58 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {

Pesanan

- - - +
+ + + + + + Koneksi Printer + + {isConnected ? ( + <> + + + Ukuran Kertas: {paperSize}mm + + + setPaperSize(v as any)}> + 58mm + 80mm + + + + + + + Putus Koneksi + + + ) : ( + <> + + + Bluetooth + + + + USB + + + )} + + + + + + +
diff --git a/resources/js/pages/admin/manage/order/partials/columns.tsx b/resources/js/pages/admin/manage/order/partials/columns.tsx index c1a6bbe..57b4ebd 100644 --- a/resources/js/pages/admin/manage/order/partials/columns.tsx +++ b/resources/js/pages/admin/manage/order/partials/columns.tsx @@ -3,7 +3,7 @@ import { Order } from '@/types/order'; import { DataTableColumnHeader } from '@/components/data-table-column-header'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; -import { Pencil, Trash2, ShoppingBag } from 'lucide-react'; +import { Pencil, Trash2, ShoppingBag, Printer } from 'lucide-react'; import { Link } from '@inertiajs/react'; import * as orderRoutes from '@/routes/order'; import { format } from 'date-fns'; @@ -12,9 +12,10 @@ import { Badge } from '@/components/ui/badge'; interface ColumnProps { onDelete: (order: Order) => void; + onPrint: (order: Order) => void; } -export const getColumns = ({ onDelete }: ColumnProps): ColumnDef[] => [ +export const getColumns = ({ onDelete, onPrint }: ColumnProps): ColumnDef[] => [ { accessorKey: "invoice_number", header: ({ column }) => ( @@ -84,7 +85,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef[] => [ {row.original.total_formatted} @@ -101,6 +102,16 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef[] => [ const order = row.original; return (
+ + + + + +

Cetak Struk

+
+