- Added approval and rejection functionality for products, including new routes and methods in the ProductController. - Implemented UI changes to display product status (pending, rejected) with appropriate badges and actions. - Introduced a RejectDialog component for providing rejection reasons. - Updated product columns to handle new actions for approving and rejecting products. - Enhanced variant management to restrict actions based on product status. - Refactored various components to improve code organization and readability.
378 lines
10 KiB
TypeScript
378 lines
10 KiB
TypeScript
import ReceiptPrinterEncoder from '@point-of-sale/receipt-printer-encoder';
|
|
import { useCallback, useRef, useState } from 'react';
|
|
|
|
export type PrinterConnectionType = 'bluetooth' | 'usb';
|
|
export type PaperWidth = 58 | 80;
|
|
|
|
interface PrinterState {
|
|
connected: boolean;
|
|
connectionType: PrinterConnectionType | null;
|
|
printerName: string | null;
|
|
}
|
|
|
|
/* ── Receipt Builder (persis seperti contoh Vue) ── */
|
|
|
|
export function getPaperColumns(paperWidth: PaperWidth): number {
|
|
return paperWidth === 58 ? 32 : 48;
|
|
}
|
|
|
|
function dashedLine(columns: number): string {
|
|
return '-'.repeat(columns);
|
|
}
|
|
|
|
export type OrderReceiptItem = {
|
|
product_name: string;
|
|
variant_name: string;
|
|
quantity: string;
|
|
unit_price: string;
|
|
subtotal: string;
|
|
};
|
|
|
|
export type OrderReceiptData = {
|
|
order_number: string;
|
|
created_at: string;
|
|
customer_name: string | null;
|
|
cashier_name: string;
|
|
items: OrderReceiptItem[];
|
|
subtotal: string;
|
|
discount: string;
|
|
nego_price: string | null;
|
|
total_amount: string;
|
|
notes: string | null;
|
|
};
|
|
|
|
export type EncodeOrderReceiptOptions = {
|
|
storeName: string;
|
|
storeAddress: string;
|
|
paperWidth: PaperWidth;
|
|
};
|
|
|
|
export function encodeOrderReceipt(
|
|
order: OrderReceiptData,
|
|
options: EncodeOrderReceiptOptions,
|
|
): Uint8Array {
|
|
const columns = getPaperColumns(options.paperWidth);
|
|
const amountColumnWidth = 14;
|
|
const labelColumnWidth = columns - amountColumnWidth;
|
|
|
|
const encoder = new ReceiptPrinterEncoder({ columns });
|
|
|
|
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')
|
|
.line(`No : ${order.order_number}`)
|
|
.line(`Tgl : ${order.created_at}`);
|
|
|
|
if (order.customer_name) {
|
|
encoder.line(`Cust : ${order.customer_name}`);
|
|
}
|
|
|
|
encoder
|
|
.line(`Kasir: ${order.cashier_name}`)
|
|
.line(dashedLine(columns));
|
|
|
|
for (const item of order.items) {
|
|
encoder
|
|
.line(item.product_name)
|
|
.line(` ${item.variant_name}`)
|
|
.table(
|
|
[
|
|
{ width: labelColumnWidth, align: 'left' },
|
|
{ width: amountColumnWidth, align: 'right' },
|
|
],
|
|
[
|
|
[
|
|
`${item.quantity} x ${item.unit_price}`,
|
|
|
|
(rowEncoder: any) =>
|
|
rowEncoder
|
|
.bold()
|
|
.text(item.subtotal)
|
|
.bold(false),
|
|
],
|
|
],
|
|
)
|
|
.newline();
|
|
}
|
|
|
|
encoder.line(dashedLine(columns));
|
|
|
|
const summaryRows = [
|
|
['Subtotal', order.subtotal],
|
|
['Diskon', order.discount],
|
|
];
|
|
|
|
if (order.nego_price) {
|
|
summaryRows.push(['Harga Nego', order.nego_price]);
|
|
}
|
|
|
|
summaryRows.push(['Total', order.total_amount]);
|
|
|
|
encoder.table(
|
|
[
|
|
{ width: labelColumnWidth, align: 'left' },
|
|
{ width: amountColumnWidth, align: 'right' },
|
|
],
|
|
summaryRows,
|
|
);
|
|
|
|
encoder
|
|
.newline()
|
|
.line(dashedLine(columns))
|
|
.align('center')
|
|
.line('Terima kasih atas kunjungannya')
|
|
.line('Silakan berbelanja kembali')
|
|
.newline(3)
|
|
.cut();
|
|
|
|
return encoder.encode();
|
|
}
|
|
|
|
/* ── Printer Connection Hook ── */
|
|
|
|
export function useThermalPrinter() {
|
|
const [state, setState] = useState<PrinterState>({
|
|
connected: false,
|
|
connectionType: null,
|
|
printerName: null,
|
|
});
|
|
|
|
const bluetoothDeviceRef = useRef<BluetoothDevice | null>(null);
|
|
const bluetoothCharacteristicRef =
|
|
useRef<BluetoothCharacteristic | null>(null);
|
|
const usbDeviceRef = useRef<USBDevice | null>(null);
|
|
|
|
const disconnect = useCallback(async () => {
|
|
try {
|
|
if (
|
|
state.connectionType === 'bluetooth' &&
|
|
bluetoothDeviceRef.current
|
|
) {
|
|
bluetoothDeviceRef.current.gatt?.disconnect();
|
|
bluetoothDeviceRef.current = null;
|
|
bluetoothCharacteristicRef.current = null;
|
|
}
|
|
|
|
if (state.connectionType === 'usb' && usbDeviceRef.current) {
|
|
usbDeviceRef.current.close();
|
|
usbDeviceRef.current = null;
|
|
}
|
|
} catch {
|
|
// silent
|
|
}
|
|
|
|
setState({
|
|
connected: false,
|
|
connectionType: null,
|
|
printerName: null,
|
|
});
|
|
}, [state.connectionType]);
|
|
|
|
const connectBluetooth = useCallback(async () => {
|
|
if (!navigator.bluetooth) {
|
|
throw new Error(
|
|
'Web Bluetooth tidak didukung di browser ini. Gunakan Chrome atau Edge.',
|
|
);
|
|
}
|
|
|
|
const device = await navigator.bluetooth.requestDevice({
|
|
acceptAllDevices: true,
|
|
optionalServices: [
|
|
'000018f0-0000-1000-8000-00805f9b34fb',
|
|
'00001800-0000-1000-8000-00805f9b34fb',
|
|
'00001801-0000-1000-8000-00805f9b34fb',
|
|
'00001101-0000-1000-8000-00805f9b34fb',
|
|
'0000ffe0-0000-1000-8000-00805f9b34fb',
|
|
'0000fee7-0000-1000-8000-00805f9b34fb',
|
|
],
|
|
});
|
|
|
|
const server = await device.gatt?.connect();
|
|
|
|
if (!server) {
|
|
throw new Error('Gagal terhubung ke perangkat Bluetooth.');
|
|
}
|
|
|
|
let characteristic: BluetoothCharacteristic | null = null;
|
|
|
|
const services = await server.getPrimaryServices();
|
|
|
|
for (const svc of services) {
|
|
try {
|
|
const chars = await svc.getCharacteristics();
|
|
const writable = chars.find(
|
|
(c) =>
|
|
c.properties.writeWithoutResponse ||
|
|
c.properties.write,
|
|
);
|
|
|
|
if (writable) {
|
|
characteristic = writable;
|
|
break;
|
|
}
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
|
|
if (!characteristic) {
|
|
throw new Error(
|
|
'Tidak ditemukan characteristic yang bisa ditulis di printer ini.',
|
|
);
|
|
}
|
|
|
|
bluetoothDeviceRef.current = device;
|
|
bluetoothCharacteristicRef.current = characteristic;
|
|
|
|
setState({
|
|
connected: true,
|
|
connectionType: 'bluetooth',
|
|
printerName: device.name ?? 'Printer Bluetooth',
|
|
});
|
|
|
|
return device.name ?? 'Printer Bluetooth';
|
|
}, []);
|
|
|
|
const connectUSB = useCallback(async () => {
|
|
if (!navigator.usb) {
|
|
throw new Error(
|
|
'Web USB tidak didukung di browser ini. Gunakan Chrome atau Edge.',
|
|
);
|
|
}
|
|
|
|
const device = await navigator.usb.requestDevice({ filters: [] });
|
|
await device.open();
|
|
|
|
if (device.configuration === null) {
|
|
await device.selectConfiguration(1);
|
|
}
|
|
|
|
const iface =
|
|
device.configuration?.interfaces.find((i) =>
|
|
i.alternate.endpoints.some((e) => e.direction === 'out'),
|
|
) ?? device.configuration?.interfaces[0];
|
|
|
|
if (iface) {
|
|
await device.claimInterface(iface.interfaceNumber);
|
|
}
|
|
|
|
usbDeviceRef.current = device;
|
|
|
|
setState({
|
|
connected: true,
|
|
connectionType: 'usb',
|
|
printerName: device.productName ?? 'Printer USB',
|
|
});
|
|
|
|
return device.productName ?? 'Printer USB';
|
|
}, []);
|
|
|
|
const sendToBluetooth = useCallback(async (data: Uint8Array) => {
|
|
const characteristic = bluetoothCharacteristicRef.current;
|
|
|
|
if (!characteristic) {
|
|
throw new Error(
|
|
'Printer Bluetooth tidak terhubung.',
|
|
);
|
|
}
|
|
|
|
const useWriteWithoutResponse =
|
|
characteristic.properties.writeWithoutResponse;
|
|
const chunkSize = 128;
|
|
|
|
for (let i = 0; i < data.length; i += chunkSize) {
|
|
const chunk = data.slice(i, i + chunkSize);
|
|
|
|
if (useWriteWithoutResponse) {
|
|
await characteristic.writeValueWithoutResponse(chunk);
|
|
} else {
|
|
await characteristic.writeValueWithResponse(chunk);
|
|
}
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
}, []);
|
|
|
|
const sendToUSB = useCallback(async (data: Uint8Array) => {
|
|
const device = usbDeviceRef.current;
|
|
|
|
if (!device) {
|
|
throw new Error('Printer USB tidak terhubung.');
|
|
}
|
|
|
|
const iface = device.configuration?.interfaces.find((i) =>
|
|
i.alternate.endpoints.some((e) => e.direction === 'out'),
|
|
);
|
|
const endpoint =
|
|
iface?.alternate.endpoints.find(
|
|
(e) => e.direction === 'out',
|
|
)?.endpointNumber ?? 1;
|
|
|
|
const chunkSize = 512;
|
|
|
|
for (let i = 0; i < data.length; i += chunkSize) {
|
|
const chunk = data.slice(i, i + chunkSize);
|
|
await device.transferOut(endpoint, chunk);
|
|
}
|
|
}, []);
|
|
|
|
const print = useCallback(
|
|
async (data: Uint8Array) => {
|
|
if (!state.connected) {
|
|
throw new Error('Tidak ada printer yang terhubung.');
|
|
}
|
|
|
|
if (state.connectionType === 'bluetooth') {
|
|
await sendToBluetooth(data);
|
|
} else if (state.connectionType === 'usb') {
|
|
await sendToUSB(data);
|
|
}
|
|
},
|
|
[state.connected, state.connectionType, sendToBluetooth, sendToUSB],
|
|
);
|
|
|
|
const connect = useCallback(
|
|
async (type: PrinterConnectionType) => {
|
|
if (state.connected) {
|
|
await disconnect();
|
|
}
|
|
|
|
if (type === 'bluetooth') {
|
|
return await connectBluetooth();
|
|
}
|
|
|
|
return await connectUSB();
|
|
},
|
|
[state.connected, disconnect, connectBluetooth, connectUSB],
|
|
);
|
|
|
|
return {
|
|
connected: state.connected,
|
|
connectionType: state.connectionType,
|
|
printerName: state.printerName,
|
|
connect,
|
|
disconnect,
|
|
print,
|
|
};
|
|
}
|