feat: implement thermal printer support with ESC/POS encoding and device connectivity hooks
This commit is contained in:
parent
0f3a4e9912
commit
08cb1a5d78
205
resources/js/hooks/use-printer.ts
Normal file
205
resources/js/hooks/use-printer.ts
Normal 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;
|
||||
}
|
||||
87
resources/js/lib/esc-pos-encoder.ts
Normal file
87
resources/js/lib/esc-pos-encoder.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user