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 { Head, Link } from '@inertiajs/react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import type { Order } from '@/types/order';
|
import type { Order } from '@/types/order';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { DataTable } from '@/components/data-table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -19,6 +33,11 @@ import {
|
|||||||
import * as orderRoutes from '@/routes/order';
|
import * as orderRoutes from '@/routes/order';
|
||||||
import { useOrderIndex } from './hooks/use-order-index';
|
import { useOrderIndex } from './hooks/use-order-index';
|
||||||
import { getColumns } from './partials/columns';
|
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[] }) {
|
export default function OrderIndex({ orders }: { orders: Order[] }) {
|
||||||
const {
|
const {
|
||||||
@ -36,7 +55,79 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
|||||||
confirmBulkDelete,
|
confirmBulkDelete,
|
||||||
} = useOrderIndex();
|
} = 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 (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
@ -46,11 +137,58 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Pesanan</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">Pesanan</h1>
|
||||||
</div>
|
</div>
|
||||||
<Link href={orderRoutes.create().url}>
|
<div className="flex items-center gap-2">
|
||||||
<Button>
|
<DropdownMenu>
|
||||||
Tambah
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<Button variant={isConnected ? "outline" : "secondary"} className="gap-2">
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
<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 { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Link } from '@inertiajs/react';
|
||||||
import * as orderRoutes from '@/routes/order';
|
import * as orderRoutes from '@/routes/order';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -12,9 +12,10 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onDelete: (order: Order) => void;
|
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",
|
accessorKey: "invoice_number",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@ -84,7 +85,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Order>[] => [
|
|||||||
{row.original.total_formatted}
|
{row.original.total_formatted}
|
||||||
</span>
|
</span>
|
||||||
<Badge className="text-[10px] h-4 py-0" variant={
|
<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 === 'pending' ? 'secondary' :
|
||||||
row.original.order_status === 'cancelled' ? 'destructive' : 'outline'
|
row.original.order_status === 'cancelled' ? 'destructive' : 'outline'
|
||||||
}>
|
}>
|
||||||
@ -101,6 +102,16 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Order>[] => [
|
|||||||
const order = row.original;
|
const order = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link href={orderRoutes.edit(order.id).url}>
|
<Link href={orderRoutes.edit(order.id).url}>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user