feat: implement thermal printer support with ESC/POS encoding and device connectivity hooks

This commit is contained in:
Yoga Pangestu 2026-04-22 20:00:11 +07:00
parent 0f3a4e9912
commit 08cb1a5d78
4 changed files with 451 additions and 10 deletions

View File

@ -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<PrinterState>(() => {
return {
device: null,
type: null,
isConnected: false,
name: null,
paperSize: '58',
};
});
const [characteristic, setCharacteristic] = useState<any>(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;
}

View File

@ -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);
}
}

View File

@ -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 (
<div className="flex flex-col gap-6 p-6">
@ -46,11 +137,58 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Pesanan</h1>
</div>
<Link href={orderRoutes.create().url}>
<Button>
Tambah
</Button>
</Link>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={isConnected ? "outline" : "secondary"} className="gap-2">
<Printer className="size-4" />
{isConnected ? name : 'Hubungkan Printer'}
{isConnected && <div className="size-2 rounded-full bg-green-500 animate-pulse" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Koneksi Printer</DropdownMenuLabel>
<DropdownMenuSeparator />
{isConnected ? (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
Ukuran Kertas: {paperSize}mm
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={paperSize} onValueChange={(v) => setPaperSize(v as any)}>
<DropdownMenuRadioItem value="58">58mm</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="80">80mm</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={disconnect} variant="destructive">
<Unplug className="mr-2 size-4" />
Putus Koneksi
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem onClick={connectBluetooth}>
<Bluetooth className="mr-2 size-4" />
Bluetooth
</DropdownMenuItem>
<DropdownMenuItem onClick={connectUsb}>
<Usb className="mr-2 size-4" />
USB
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Link href={orderRoutes.create().url}>
<Button>
Tambah
</Button>
</Link>
</div>
</div>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">

View File

@ -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<Order>[] => [
export const getColumns = ({ onDelete, onPrint }: ColumnProps): ColumnDef<Order>[] => [
{
accessorKey: "invoice_number",
header: ({ column }) => (
@ -84,7 +85,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Order>[] => [
{row.original.total_formatted}
</span>
<Badge className="text-[10px] h-4 py-0" variant={
row.original.order_status === 'completed' ? 'default' :
row.original.order_status === 'delivered' ? 'default' :
row.original.order_status === 'pending' ? 'secondary' :
row.original.order_status === 'cancelled' ? 'destructive' : 'outline'
}>
@ -101,6 +102,16 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Order>[] => [
const order = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20' onClick={() => onPrint(order)}>
<Printer className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Cetak Struk</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link href={orderRoutes.edit(order.id).url}>