Add thermal printer integration with USB and Bluetooth support. Implement receipt printing functionality using @point-of-sale libraries. Create UI components for printer connection management and order receipt printing. Update OrderStatus and app settings to accommodate new features.
This commit is contained in:
parent
5233890030
commit
93992a0d3a
@ -76,7 +76,7 @@ public function availableActions(): array
|
|||||||
'label' => 'Selesai',
|
'label' => 'Selesai',
|
||||||
'destructive' => false,
|
'destructive' => false,
|
||||||
'permission' => Permission::ORDERS_COMPLETE->value,
|
'permission' => Permission::ORDERS_COMPLETE->value,
|
||||||
'icon_only' => false,
|
'icon_only' => true,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'status' => self::CANCELLED->value,
|
'status' => self::CANCELLED->value,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Settings\SystemSettings;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@ -37,7 +38,8 @@ public function share(Request $request): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => fn () => app(SystemSettings::class)->app_name,
|
||||||
|
'address' => fn () => app(SystemSettings::class)->address,
|
||||||
'auth' => fn () => [
|
'auth' => fn () => [
|
||||||
'user' => $request->user() ? [
|
'user' => $request->user() ? [
|
||||||
'id' => $request->user()->id,
|
'id' => $request->user()->id,
|
||||||
|
|||||||
59
package-lock.json
generated
59
package-lock.json
generated
@ -13,6 +13,9 @@
|
|||||||
"@inertiajs/vite": "^3.0.0",
|
"@inertiajs/vite": "^3.0.0",
|
||||||
"@inertiajs/vue3": "^3.0.0",
|
"@inertiajs/vue3": "^3.0.0",
|
||||||
"@lucide/vue": "^1.17.0",
|
"@lucide/vue": "^1.17.0",
|
||||||
|
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
|
||||||
|
"@point-of-sale/webbluetooth-receipt-printer": "^2.0.0",
|
||||||
|
"@point-of-sale/webserial-receipt-printer": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
@ -1681,7 +1684,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.1.0.tgz",
|
||||||
"integrity": "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==",
|
"integrity": "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
@ -2689,6 +2691,37 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@point-of-sale/codepage-encoder": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@point-of-sale/codepage-encoder/-/codepage-encoder-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sx+0sE/XgPtMUNKScHpwTIZiHNd/+Mg0udACQJVwPsxH9kUAgIkRi9kRlmWBMCruwOJadjKLyKs8MO8Bh+VS/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@point-of-sale/receipt-printer-encoder": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@point-of-sale/receipt-printer-encoder/-/receipt-printer-encoder-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-2+xgs6rwNfrkEw9g4LkgQS6k29qnmo3+hOV7Z2xHNBFn8h4FrwquZwL/dBOTwgmQ9sWEYBkj/bQkg9Rz6lL99w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@canvas/image-data": "^1.0.0",
|
||||||
|
"@point-of-sale/codepage-encoder": "^3.0.0",
|
||||||
|
"canvas-dither": "^1.0.1",
|
||||||
|
"canvas-flatten": "^1.0.1",
|
||||||
|
"resize-image-data": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@point-of-sale/webbluetooth-receipt-printer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@point-of-sale/webbluetooth-receipt-printer/-/webbluetooth-receipt-printer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-xtplxsZWuhKa7DFASYzV8PSiRzwzjbQLqAsWSdjpIVkEo9Xmo652Pcu3aKbRUu4Ih2k9Fz4weyUZljOgbyDYkg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@point-of-sale/webserial-receipt-printer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@point-of-sale/webserial-receipt-printer/-/webserial-receipt-printer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-txTb+DTdkrl2c3oxtIAYc7vKGb4fJMPGnC8iCYMETeAhcFZP2yeISyCa0DVgw4YXNCmsOv6mI6aYzqUjuBb+wQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@quansync/fs": {
|
"node_modules/@quansync/fs": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
||||||
@ -5236,6 +5269,18 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/canvas-dither": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvas-dither/-/canvas-dither-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-wT1RnV2y9SauibcsfMMCagQTnXmyQq2nJXuX5tTEvo0sXm1vVbJ2r8QxSC3fRQMY3ZgZVq1j54cOvmO4u+Lu/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/canvas-flatten": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvas-flatten/-/canvas-flatten-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UhHeKeWGS23Bm5teZiHk6NEOme1OKCpv6arFovV89dydt0Xz+U4HkPXAKBZS/hmpR15x5U9tDj1ieMQMTwvCAA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@ -8965,6 +9010,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resize-image-data": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/resize-image-data/-/resize-image-data-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-6hVRn2S6W1cdycreA6Vth5XRN2NnGs7/RnVpxNw/1OCK8aCoevRFH2WprmQRZDnnH3e6awLv2tTIPuv7/7xeGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@canvas/image-data": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
|
|||||||
@ -41,6 +41,9 @@
|
|||||||
"@inertiajs/vite": "^3.0.0",
|
"@inertiajs/vite": "^3.0.0",
|
||||||
"@inertiajs/vue3": "^3.0.0",
|
"@inertiajs/vue3": "^3.0.0",
|
||||||
"@lucide/vue": "^1.17.0",
|
"@lucide/vue": "^1.17.0",
|
||||||
|
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
|
||||||
|
"@point-of-sale/webbluetooth-receipt-printer": "^2.0.0",
|
||||||
|
"@point-of-sale/webserial-receipt-printer": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { createApp, h } from 'vue';
|
|||||||
import 'vue-sonner/style.css';
|
import 'vue-sonner/style.css';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useFlashToast } from '@/composables/useFlashToast';
|
import { useFlashToast } from '@/composables/useFlashToast';
|
||||||
|
import { restoreConnection } from '@/lib/thermal-printer/stable-transport';
|
||||||
|
|
||||||
registerSW({ immediate: true });
|
registerSW({ immediate: true });
|
||||||
|
void restoreConnection();
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
|
|||||||
@ -103,9 +103,6 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
|
|||||||
<p v-if="order.customer">
|
<p v-if="order.customer">
|
||||||
{{ order.customer.name }}
|
{{ order.customer.name }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else>
|
|
||||||
Tanpa pelanggan
|
|
||||||
</p>
|
|
||||||
<p>{{ order.created_at_formatted }}</p>
|
<p>{{ order.created_at_formatted }}</p>
|
||||||
<p>Oleh {{ order.created_by?.profile?.full_name ?? order.created_by?.username }}</p>
|
<p>Oleh {{ order.created_by?.profile?.full_name ?? order.created_by?.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
import { Printer } from '@lucide/vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { useThermalPrinter, getPaperSizeLabel } from '@/composables/useThermalPrinter';
|
||||||
|
import type { PaperSize } from '@/lib/thermal-printer/types';
|
||||||
|
import type { OrderListItem } from '@/types/order';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
order: OrderListItem;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const open = ref(false);
|
||||||
|
const processing = ref(false);
|
||||||
|
|
||||||
|
const { isConnected, printOrderReceipt, tryReconnect } = useThermalPrinter();
|
||||||
|
|
||||||
|
const storeName = computed(() => page.props.name);
|
||||||
|
const storeAddress = computed(() => page.props.address);
|
||||||
|
|
||||||
|
const paperSizes: PaperSize[] = ['58mm', '80mm'];
|
||||||
|
|
||||||
|
async function handlePrint(paperSize: PaperSize) {
|
||||||
|
if (processing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnected.value) {
|
||||||
|
const restored = await tryReconnect();
|
||||||
|
|
||||||
|
if (!restored) {
|
||||||
|
toast.error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open.value = false;
|
||||||
|
processing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await printOrderReceipt({
|
||||||
|
order: props.order,
|
||||||
|
paperSize,
|
||||||
|
storeName: storeName.value,
|
||||||
|
storeAddress: storeAddress.value ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Struk ${props.order.order_number} dikirim ke printer ${getPaperSizeLabel(paperSize)}.`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Gagal mencetak struk pesanan.';
|
||||||
|
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
processing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Popover v-model:open="open">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="ghost" size="icon" class="size-8" :disabled="processing" title="Cetak struk">
|
||||||
|
<Printer class="size-4" />
|
||||||
|
<span class="sr-only">Cetak struk</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent align="end" class="w-40 space-y-2 p-3">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
Ukuran kertas
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button v-for="paperSize in paperSizes" :key="paperSize" variant="outline" size="sm" class="h-8"
|
||||||
|
:disabled="processing" @click="handlePrint(paperSize)">
|
||||||
|
{{ paperSize }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Bluetooth, PlugZap, Usb } from '@lucide/vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { useThermalPrinter } from '@/composables/useThermalPrinter';
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
connectionLabel,
|
||||||
|
connecting,
|
||||||
|
connectUsb,
|
||||||
|
connectBluetooth,
|
||||||
|
tryReconnect,
|
||||||
|
disconnect,
|
||||||
|
supportsUsb,
|
||||||
|
supportsBluetooth,
|
||||||
|
} = useThermalPrinter();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await tryReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleConnectUsb() {
|
||||||
|
try {
|
||||||
|
await connectUsb();
|
||||||
|
open.value = false;
|
||||||
|
toast.success('Printer USB terhubung.');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Gagal menghubungkan printer USB.';
|
||||||
|
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnectBluetooth() {
|
||||||
|
try {
|
||||||
|
await connectBluetooth();
|
||||||
|
open.value = false;
|
||||||
|
toast.success('Printer Bluetooth terhubung.');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Gagal menghubungkan printer Bluetooth.';
|
||||||
|
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect() {
|
||||||
|
disconnect();
|
||||||
|
open.value = false;
|
||||||
|
toast.success('Printer terputus.');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Popover v-model:open="open">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="outline" class="shrink-0 gap-2 self-start sm:self-center">
|
||||||
|
<PlugZap class="size-4" />
|
||||||
|
Printer
|
||||||
|
<Badge
|
||||||
|
:variant="isConnected ? 'default' : 'secondary'"
|
||||||
|
class="rounded-full px-2 text-xs"
|
||||||
|
>
|
||||||
|
{{ isConnected ? connectionLabel : 'Offline' }}
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent align="end" class="w-64 space-y-3 p-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
Koneksi Printer
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Hubungkan printer thermal sebelum mencetak struk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isConnected" class="rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||||
|
Terhubung via <strong>{{ connectionLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="justify-start gap-2"
|
||||||
|
:disabled="!supportsUsb || connecting"
|
||||||
|
@click="handleConnectUsb"
|
||||||
|
>
|
||||||
|
<Usb class="size-4" />
|
||||||
|
{{ isConnected && connectionLabel === 'USB' ? 'Hubungkan ulang USB' : 'Hubungkan USB' }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="justify-start gap-2"
|
||||||
|
:disabled="!supportsBluetooth || connecting"
|
||||||
|
@click="handleConnectBluetooth"
|
||||||
|
>
|
||||||
|
<Bluetooth class="size-4" />
|
||||||
|
{{ isConnected && connectionLabel === 'Bluetooth' ? 'Hubungkan ulang Bluetooth' : 'Hubungkan Bluetooth' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="isConnected"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="connecting"
|
||||||
|
@click="handleDisconnect"
|
||||||
|
>
|
||||||
|
Putuskan koneksi
|
||||||
|
</Button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
@ -3,6 +3,7 @@ import { Link, router } from '@inertiajs/vue3';
|
|||||||
import { Check, Pencil, Send, Trash2, X } from '@lucide/vue';
|
import { Check, Pencil, Send, Trash2, X } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
|
import OrderPrintButton from '@/components/admin/manage/orders/OrderPrintButton.vue';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
@ -105,6 +106,8 @@ function actionIcon(status: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||||
|
<OrderPrintButton :order="order" />
|
||||||
|
|
||||||
<template v-for="action in availableActions" :key="action.status">
|
<template v-for="action in availableActions" :key="action.status">
|
||||||
<Tooltip v-if="action.icon_only && canPerformAction(action)">
|
<Tooltip v-if="action.icon_only && canPerformAction(action)">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
|
|||||||
104
resources/js/composables/useThermalPrinter.ts
Normal file
104
resources/js/composables/useThermalPrinter.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { encodeOrderReceipt } from '@/lib/thermal-printer/encode-order-receipt';
|
||||||
|
import { normalizeEncoderOptions } from '@/lib/thermal-printer/normalize-encoder-options';
|
||||||
|
import {
|
||||||
|
connectBluetooth,
|
||||||
|
connectionState,
|
||||||
|
connectUsb,
|
||||||
|
disconnect as disconnectTransport,
|
||||||
|
getBluetoothEncoderSettings,
|
||||||
|
isWebBluetoothSupported,
|
||||||
|
isWebSerialSupported,
|
||||||
|
restoreConnection,
|
||||||
|
sendPrintData,
|
||||||
|
tryReconnect as tryReconnectTransport,
|
||||||
|
} from '@/lib/thermal-printer/stable-transport';
|
||||||
|
import type { PaperSize } from '@/lib/thermal-printer/types';
|
||||||
|
import type { OrderListItem } from '@/types/order';
|
||||||
|
|
||||||
|
const connecting = ref(false);
|
||||||
|
|
||||||
|
async function withConnecting<T>(callback: () => Promise<T>): Promise<T> {
|
||||||
|
connecting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await callback();
|
||||||
|
} finally {
|
||||||
|
connecting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrintOrderReceiptOptions = {
|
||||||
|
order: OrderListItem;
|
||||||
|
paperSize: PaperSize;
|
||||||
|
storeName: string;
|
||||||
|
storeAddress: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function printOrderReceipt(options: PrintOrderReceiptOptions): Promise<void> {
|
||||||
|
const { order, paperSize, storeName, storeAddress } = options;
|
||||||
|
let connection = connectionState.value;
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
const restored = await restoreConnection();
|
||||||
|
connection = connectionState.value;
|
||||||
|
|
||||||
|
if (!restored || !connection) {
|
||||||
|
throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bluetoothSettings = connection === 'bluetooth'
|
||||||
|
? getBluetoothEncoderSettings()
|
||||||
|
: null;
|
||||||
|
const encoderOptions = normalizeEncoderOptions({
|
||||||
|
language: bluetoothSettings?.language,
|
||||||
|
codepageMapping: bluetoothSettings?.codepageMapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
const encoded = encodeOrderReceipt(order, {
|
||||||
|
storeName,
|
||||||
|
storeAddress,
|
||||||
|
paperSize,
|
||||||
|
language: connection === 'bluetooth' ? encoderOptions.language : undefined,
|
||||||
|
codepageMapping: connection === 'bluetooth' ? encoderOptions.codepageMapping : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendPrintData(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThermalPrinter() {
|
||||||
|
const isConnected = computed(() => connectionState.value !== null);
|
||||||
|
|
||||||
|
const connectionLabel = computed(() => {
|
||||||
|
const connection = connectionState.value;
|
||||||
|
|
||||||
|
if (connection === 'usb') {
|
||||||
|
return 'USB';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'bluetooth') {
|
||||||
|
return 'Bluetooth';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeConnection: connectionState,
|
||||||
|
isConnected,
|
||||||
|
connectionLabel,
|
||||||
|
connecting,
|
||||||
|
connectUsb: () => withConnecting(connectUsb),
|
||||||
|
connectBluetooth: () => withConnecting(connectBluetooth),
|
||||||
|
tryReconnect: () => withConnecting(tryReconnectTransport),
|
||||||
|
disconnect: disconnectTransport,
|
||||||
|
printOrderReceipt,
|
||||||
|
supportsUsb: isWebSerialSupported(),
|
||||||
|
supportsBluetooth: isWebBluetoothSupported(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaperSizeLabel(paperSize: PaperSize): string {
|
||||||
|
return paperSize;
|
||||||
|
}
|
||||||
134
resources/js/lib/thermal-printer/encode-order-receipt.ts
Normal file
134
resources/js/lib/thermal-printer/encode-order-receipt.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import ReceiptPrinterEncoder from '@point-of-sale/receipt-printer-encoder';
|
||||||
|
import { normalizeEncoderOptions } from '@/lib/thermal-printer/normalize-encoder-options';
|
||||||
|
import type { PaperSize } from '@/lib/thermal-printer/types';
|
||||||
|
import type { OrderListItem } from '@/types/order';
|
||||||
|
|
||||||
|
export type EncodeOrderReceiptOptions = {
|
||||||
|
storeName: string;
|
||||||
|
storeAddress: string;
|
||||||
|
paperSize: PaperSize;
|
||||||
|
language?: string | null;
|
||||||
|
codepageMapping?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPaperColumns(paperSize: PaperSize): number {
|
||||||
|
return paperSize === '58mm' ? 32 : 48;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashedLine(columns: number): string {
|
||||||
|
return '-'.repeat(columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeOrderReceipt(
|
||||||
|
order: OrderListItem,
|
||||||
|
options: EncodeOrderReceiptOptions,
|
||||||
|
): Uint8Array {
|
||||||
|
const columns = getPaperColumns(options.paperSize);
|
||||||
|
const amountColumnWidth = 14;
|
||||||
|
const labelColumnWidth = columns - amountColumnWidth;
|
||||||
|
const encoderOptions = normalizeEncoderOptions({
|
||||||
|
language: options.language,
|
||||||
|
codepageMapping: options.codepageMapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
const encoder = new ReceiptPrinterEncoder({
|
||||||
|
language: encoderOptions.language,
|
||||||
|
codepageMapping: encoderOptions.codepageMapping,
|
||||||
|
columns,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdBy = order.created_by?.profile?.full_name
|
||||||
|
?? order.created_by?.username
|
||||||
|
?? '-';
|
||||||
|
const isMarketplace = order.channel !== 'store';
|
||||||
|
|
||||||
|
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');
|
||||||
|
encoder.line(`No : ${order.order_number}`);
|
||||||
|
encoder.line(`Tgl : ${order.created_at_formatted}`);
|
||||||
|
|
||||||
|
if (order.customer) {
|
||||||
|
encoder.line(`Cust : ${order.customer.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder
|
||||||
|
.line(`Kasir: ${createdBy}`)
|
||||||
|
.line(dashedLine(columns));
|
||||||
|
|
||||||
|
for (const item of order.items) {
|
||||||
|
const productName = item.product_variant?.product?.name ?? '-';
|
||||||
|
const variantName = item.product_variant?.name ?? '-';
|
||||||
|
|
||||||
|
encoder
|
||||||
|
.line(productName)
|
||||||
|
.line(` ${variantName}`)
|
||||||
|
.table(
|
||||||
|
[
|
||||||
|
{ width: labelColumnWidth, align: 'left' },
|
||||||
|
{ width: amountColumnWidth, align: 'right' },
|
||||||
|
],
|
||||||
|
[[
|
||||||
|
`${item.quantity_formatted} x ${item.unit_price_formatted}`,
|
||||||
|
(rowEncoder) => rowEncoder.bold().text(item.subtotal_formatted).bold(false),
|
||||||
|
]],
|
||||||
|
)
|
||||||
|
.newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder.line(dashedLine(columns));
|
||||||
|
|
||||||
|
const summaryRows: string[][] = [
|
||||||
|
['Subtotal', order.subtotal_formatted],
|
||||||
|
['Diskon', order.discount_formatted],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isMarketplace) {
|
||||||
|
summaryRows.push(['Biaya MP', order.marketplace_fee_formatted]);
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryRows.push(['Total', order.net_amount_formatted]);
|
||||||
|
|
||||||
|
encoder.table(
|
||||||
|
[
|
||||||
|
{ width: labelColumnWidth, align: 'left' },
|
||||||
|
{ width: amountColumnWidth, align: 'right' },
|
||||||
|
],
|
||||||
|
summaryRows,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (order.notes) {
|
||||||
|
encoder
|
||||||
|
.newline()
|
||||||
|
.line('Catatan:')
|
||||||
|
.line(order.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder
|
||||||
|
.newline()
|
||||||
|
.line(dashedLine(columns))
|
||||||
|
.align('center')
|
||||||
|
.line('Terima kasih atas kunjungannya')
|
||||||
|
.line('Silakan berbelanja kembali')
|
||||||
|
.newline(3)
|
||||||
|
.cut();
|
||||||
|
|
||||||
|
return encoder.encode();
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
export type EncoderLanguage = 'esc-pos' | 'star-prnt' | 'star-line';
|
||||||
|
|
||||||
|
const ESC_POS_CODEPAGE_MAPPINGS = new Set([
|
||||||
|
'bixolon/legacy',
|
||||||
|
'bixolon',
|
||||||
|
'citizen',
|
||||||
|
'epson/legacy',
|
||||||
|
'epson',
|
||||||
|
'fujitsu',
|
||||||
|
'hp',
|
||||||
|
'metapace',
|
||||||
|
'mpt',
|
||||||
|
'pos-5890',
|
||||||
|
'pos-8360',
|
||||||
|
'star',
|
||||||
|
'xprinter',
|
||||||
|
'youku',
|
||||||
|
'zijang',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const STAR_CODEPAGE_MAPPINGS = new Set(['star']);
|
||||||
|
|
||||||
|
const CODEPAGE_ALIASES: Record<string, string> = {
|
||||||
|
default: 'epson',
|
||||||
|
zjiang: 'zijang',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeEncoderLanguage(language?: string | null): EncoderLanguage {
|
||||||
|
if (language === 'star-prnt' || language === 'star-line') {
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'esc-pos';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCodepageMapping(
|
||||||
|
language: EncoderLanguage,
|
||||||
|
codepageMapping?: string | null,
|
||||||
|
): string {
|
||||||
|
const mapping = codepageMapping
|
||||||
|
? CODEPAGE_ALIASES[codepageMapping] ?? codepageMapping
|
||||||
|
: 'epson';
|
||||||
|
|
||||||
|
if (language === 'star-prnt' || language === 'star-line') {
|
||||||
|
return STAR_CODEPAGE_MAPPINGS.has(mapping) ? mapping : 'star';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ESC_POS_CODEPAGE_MAPPINGS.has(mapping) ? mapping : 'epson';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEncoderOptions(options?: {
|
||||||
|
language?: string | null;
|
||||||
|
codepageMapping?: string | null;
|
||||||
|
}): {
|
||||||
|
language: EncoderLanguage;
|
||||||
|
codepageMapping: string;
|
||||||
|
} {
|
||||||
|
const language = normalizeEncoderLanguage(options?.language);
|
||||||
|
|
||||||
|
return {
|
||||||
|
language,
|
||||||
|
codepageMapping: normalizeCodepageMapping(language, options?.codepageMapping),
|
||||||
|
};
|
||||||
|
}
|
||||||
565
resources/js/lib/thermal-printer/stable-transport.ts
Normal file
565
resources/js/lib/thermal-printer/stable-transport.ts
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { normalizeEncoderOptions } from '@/lib/thermal-printer/normalize-encoder-options';
|
||||||
|
import type { PrinterConnection, StoredBluetoothDevice, StoredUsbDevice } from '@/lib/thermal-printer/types';
|
||||||
|
|
||||||
|
const USB_STORAGE_KEY = 'thermal-printer.usb-device';
|
||||||
|
const BLUETOOTH_STORAGE_KEY = 'thermal-printer.bluetooth-device';
|
||||||
|
const CONNECTION_STORAGE_KEY = 'thermal-printer.active-connection';
|
||||||
|
|
||||||
|
const USB_CHUNK_SIZE = 256;
|
||||||
|
const USB_CHUNK_DELAY_MS = 15;
|
||||||
|
const USB_FLUSH_DELAY_MS = 200;
|
||||||
|
|
||||||
|
type BleProfile = {
|
||||||
|
service: string;
|
||||||
|
characteristic: string;
|
||||||
|
language: string;
|
||||||
|
codepageMapping: string;
|
||||||
|
chunkSize: number;
|
||||||
|
chunkDelayMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLE_PROFILES: BleProfile[] = [
|
||||||
|
{
|
||||||
|
service: '000018f0-0000-1000-8000-00805f9b34fb',
|
||||||
|
characteristic: '00002af1-0000-1000-8000-00805f9b34fb',
|
||||||
|
language: 'esc-pos',
|
||||||
|
codepageMapping: 'xprinter',
|
||||||
|
chunkSize: 50,
|
||||||
|
chunkDelayMs: 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: '49535343-fe7d-4ae5-8fa9-9fafd205e455',
|
||||||
|
characteristic: '49535343-8841-43f4-a8d4-ecbe34729bb3',
|
||||||
|
language: 'esc-pos',
|
||||||
|
codepageMapping: 'epson',
|
||||||
|
chunkSize: 100,
|
||||||
|
chunkDelayMs: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: '0000ae30-0000-1000-8000-00805f9b34fb',
|
||||||
|
characteristic: '0000ae01-0000-1000-8000-00805f9b34fb',
|
||||||
|
language: 'esc-pos',
|
||||||
|
codepageMapping: 'epson',
|
||||||
|
chunkSize: 100,
|
||||||
|
chunkDelayMs: 35,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const connectionState = ref<PrinterConnection | null>(null);
|
||||||
|
|
||||||
|
let activeConnection: PrinterConnection | null = null;
|
||||||
|
let usbPort: SerialPort | null = null;
|
||||||
|
let bluetoothDevice: BluetoothDevice | null = null;
|
||||||
|
let bluetoothCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
|
||||||
|
let activeBleProfile: BleProfile | null = null;
|
||||||
|
let bluetoothEncoderSettings: StoredBluetoothDevice | null = null;
|
||||||
|
let printQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredConnection(): PrinterConnection | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CONNECTION_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (raw === 'usb' || raw === 'bluetooth') {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeActiveConnection(connection: PrinterConnection | null): void {
|
||||||
|
if (connection) {
|
||||||
|
localStorage.setItem(CONNECTION_STORAGE_KEY, connection);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(CONNECTION_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredUsbDevice(): StoredUsbDevice | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(USB_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(raw) as StoredUsbDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeUsbDevice(device: StoredUsbDevice): void {
|
||||||
|
localStorage.setItem(USB_STORAGE_KEY, JSON.stringify(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredBluetoothDevice(): StoredBluetoothDevice | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(BLUETOOTH_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(raw) as StoredBluetoothDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeBluetoothDevice(device: StoredBluetoothDevice): void {
|
||||||
|
localStorage.setItem(BLUETOOTH_STORAGE_KEY, JSON.stringify(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUsbState(): void {
|
||||||
|
usbPort = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBluetoothState(): void {
|
||||||
|
bluetoothDevice = null;
|
||||||
|
bluetoothCharacteristic = null;
|
||||||
|
activeBleProfile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveConnection(connection: PrinterConnection | null): void {
|
||||||
|
activeConnection = connection;
|
||||||
|
connectionState.value = connection;
|
||||||
|
storeActiveConnection(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDisconnected(): void {
|
||||||
|
setActiveConnection(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUsbDisconnectHandler(port: SerialPort): void {
|
||||||
|
port.addEventListener('disconnect', () => {
|
||||||
|
if (activeConnection === 'usb') {
|
||||||
|
clearUsbState();
|
||||||
|
markDisconnected();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUsbPort(port: SerialPort): Promise<void> {
|
||||||
|
if (!port.readable && !port.writable) {
|
||||||
|
await port.open({
|
||||||
|
baudRate: 9600,
|
||||||
|
dataBits: 8,
|
||||||
|
stopBits: 1,
|
||||||
|
parity: 'none',
|
||||||
|
flowControl: 'none',
|
||||||
|
bufferSize: 255,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = port.getInfo();
|
||||||
|
|
||||||
|
usbPort = port;
|
||||||
|
setActiveConnection('usb');
|
||||||
|
bindUsbDisconnectHandler(port);
|
||||||
|
|
||||||
|
if (info.usbVendorId && info.usbProductId) {
|
||||||
|
storeUsbDevice({
|
||||||
|
vendorId: info.usbVendorId,
|
||||||
|
productId: info.usbProductId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBluetoothPrintChannel(device: BluetoothDevice): Promise<{
|
||||||
|
characteristic: BluetoothRemoteGATTCharacteristic;
|
||||||
|
profile: BleProfile;
|
||||||
|
}> {
|
||||||
|
const server = device.gatt;
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error('Printer Bluetooth tidak mendukung GATT.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.connected) {
|
||||||
|
await server.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = await server.getPrimaryServices().catch(() => []);
|
||||||
|
const serviceIds = new Set(services.map((service) => service.uuid));
|
||||||
|
|
||||||
|
for (const profile of BLE_PROFILES) {
|
||||||
|
if (!serviceIds.has(profile.service)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = await server.getPrimaryService(profile.service);
|
||||||
|
const characteristic = await service.getCharacteristic(profile.characteristic);
|
||||||
|
|
||||||
|
return { characteristic, profile };
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of BLE_PROFILES) {
|
||||||
|
try {
|
||||||
|
const service = await server.getPrimaryService(profile.service);
|
||||||
|
const characteristic = await service.getCharacteristic(profile.characteristic);
|
||||||
|
|
||||||
|
return { characteristic, profile };
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Service cetak printer Bluetooth tidak ditemukan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachBluetoothDevice(device: BluetoothDevice): Promise<void> {
|
||||||
|
const { characteristic, profile } = await resolveBluetoothPrintChannel(device);
|
||||||
|
const encoderOptions = normalizeEncoderOptions({
|
||||||
|
language: profile.language,
|
||||||
|
codepageMapping: profile.codepageMapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
bluetoothDevice = device;
|
||||||
|
bluetoothCharacteristic = characteristic;
|
||||||
|
activeBleProfile = profile;
|
||||||
|
bluetoothEncoderSettings = {
|
||||||
|
id: device.id,
|
||||||
|
language: encoderOptions.language,
|
||||||
|
codepageMapping: encoderOptions.codepageMapping,
|
||||||
|
};
|
||||||
|
|
||||||
|
setActiveConnection('bluetooth');
|
||||||
|
storeBluetoothDevice(bluetoothEncoderSettings);
|
||||||
|
|
||||||
|
device.addEventListener('gattserverdisconnected', () => {
|
||||||
|
if (activeConnection === 'bluetooth') {
|
||||||
|
clearBluetoothState();
|
||||||
|
markDisconnected();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeBluetoothChunk(
|
||||||
|
characteristic: BluetoothRemoteGATTCharacteristic,
|
||||||
|
chunk: Uint8Array,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await characteristic.writeValueWithResponse(chunk);
|
||||||
|
} catch {
|
||||||
|
await characteristic.writeValueWithoutResponse(chunk);
|
||||||
|
await sleep(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendUsbData(data: Uint8Array): Promise<void> {
|
||||||
|
if (!usbPort?.writable) {
|
||||||
|
throw new Error('Port USB printer tidak siap.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const writer = usbPort.writable.getWriter();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let offset = 0; offset < data.length; offset += USB_CHUNK_SIZE) {
|
||||||
|
const chunk = data.slice(offset, offset + USB_CHUNK_SIZE);
|
||||||
|
await writer.write(chunk);
|
||||||
|
|
||||||
|
if (offset + USB_CHUNK_SIZE < data.length) {
|
||||||
|
await sleep(USB_CHUNK_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.ready;
|
||||||
|
} finally {
|
||||||
|
writer.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(USB_FLUSH_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBluetoothData(data: Uint8Array): Promise<void> {
|
||||||
|
if (!bluetoothCharacteristic || !activeBleProfile) {
|
||||||
|
throw new Error('Printer Bluetooth belum siap.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chunkSize, chunkDelayMs } = activeBleProfile;
|
||||||
|
|
||||||
|
for (let offset = 0; offset < data.length; offset += chunkSize) {
|
||||||
|
const chunk = data.slice(offset, offset + chunkSize);
|
||||||
|
await writeBluetoothChunk(bluetoothCharacteristic, chunk);
|
||||||
|
|
||||||
|
if (offset + chunkSize < data.length) {
|
||||||
|
await sleep(chunkDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnectUsb(): Promise<void> {
|
||||||
|
const stored = readStoredUsbDevice();
|
||||||
|
const ports = await navigator.serial!.getPorts();
|
||||||
|
|
||||||
|
if (!ports.length) {
|
||||||
|
throw new Error('Printer USB tidak ditemukan. Hubungkan ulang secara manual.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedPorts = [
|
||||||
|
...ports.filter((candidate) => {
|
||||||
|
const info = candidate.getInfo();
|
||||||
|
|
||||||
|
return stored?.vendorId === info.usbVendorId
|
||||||
|
&& stored?.productId === info.usbProductId;
|
||||||
|
}),
|
||||||
|
...ports.filter((candidate) => {
|
||||||
|
const info = candidate.getInfo();
|
||||||
|
|
||||||
|
return stored?.vendorId !== info.usbVendorId
|
||||||
|
|| stored?.productId !== info.usbProductId;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
clearUsbState();
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (const port of orderedPorts) {
|
||||||
|
try {
|
||||||
|
await openUsbPort(port);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('Gagal membuka port USB printer.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('Printer USB tidak ditemukan. Hubungkan ulang secara manual.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnectBluetooth(): Promise<void> {
|
||||||
|
const stored = readStoredBluetoothDevice();
|
||||||
|
|
||||||
|
if (!stored?.id || !navigator.bluetooth?.getDevices) {
|
||||||
|
throw new Error('Printer Bluetooth tidak ditemukan. Hubungkan ulang secara manual.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await navigator.bluetooth.getDevices();
|
||||||
|
const device = devices.find((candidate) => candidate.id === stored.id);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new Error('Printer Bluetooth tidak ditemukan. Hubungkan ulang secara manual.');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBluetoothState();
|
||||||
|
await attachBluetoothDevice(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreStoredConnection(): Promise<boolean> {
|
||||||
|
const storedConnection = readStoredConnection();
|
||||||
|
|
||||||
|
if (!storedConnection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (storedConnection === 'usb' && isWebSerialSupported()) {
|
||||||
|
await reconnectUsb();
|
||||||
|
|
||||||
|
return activeConnection === 'usb';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedConnection === 'bluetooth' && isWebBluetoothSupported()) {
|
||||||
|
await reconnectBluetooth();
|
||||||
|
|
||||||
|
return activeConnection === 'bluetooth';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearUsbState();
|
||||||
|
clearBluetoothState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureReady(): Promise<void> {
|
||||||
|
if (!activeConnection) {
|
||||||
|
const restored = await restoreStoredConnection();
|
||||||
|
|
||||||
|
if (!restored) {
|
||||||
|
throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConnection === 'usb') {
|
||||||
|
if (usbPort?.writable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconnectUsb();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConnection === 'bluetooth') {
|
||||||
|
if (bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconnectBluetooth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceReconnect(): Promise<void> {
|
||||||
|
const connection = activeConnection ?? readStoredConnection();
|
||||||
|
|
||||||
|
if (connection === 'usb') {
|
||||||
|
await reconnectUsb();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'bluetooth') {
|
||||||
|
await reconnectBluetooth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPrintDataInternal(data: Uint8Array): Promise<void> {
|
||||||
|
if (!activeConnection) {
|
||||||
|
throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureReady();
|
||||||
|
|
||||||
|
if (activeConnection === 'usb') {
|
||||||
|
await sendUsbData(data);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConnection === 'bluetooth') {
|
||||||
|
await sendBluetoothData(data);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Koneksi printer tidak dikenali.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveConnection(): PrinterConnection | null {
|
||||||
|
return activeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBluetoothEncoderSettings(): StoredBluetoothDevice | null {
|
||||||
|
return bluetoothEncoderSettings ?? readStoredBluetoothDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebSerialSupported(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && 'serial' in navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebBluetoothSupported(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && 'bluetooth' in navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectUsb(): Promise<void> {
|
||||||
|
if (!isWebSerialSupported()) {
|
||||||
|
throw new Error('Browser tidak mendukung Web Serial. Gunakan Chrome/Edge dan sambungkan printer via USB.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = await navigator.serial!.requestPort();
|
||||||
|
clearUsbState();
|
||||||
|
await openUsbPort(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectBluetooth(): Promise<void> {
|
||||||
|
if (!isWebBluetoothSupported()) {
|
||||||
|
throw new Error('Browser tidak mendukung Web Bluetooth. Gunakan Chrome/Edge dan aktifkan Bluetooth.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await navigator.bluetooth!.requestDevice({
|
||||||
|
acceptAllDevices: true,
|
||||||
|
optionalServices: BLE_PROFILES.map((profile) => profile.service),
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBluetoothState();
|
||||||
|
await attachBluetoothDevice(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreConnection(): Promise<boolean> {
|
||||||
|
if (activeConnection === 'usb' && usbPort?.writable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConnection === 'bluetooth' && bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreStoredConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryReconnect(): Promise<boolean> {
|
||||||
|
return restoreConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnect(): void {
|
||||||
|
if (usbPort) {
|
||||||
|
void usbPort.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bluetoothDevice?.gatt?.connected) {
|
||||||
|
bluetoothDevice.gatt.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUsbState();
|
||||||
|
clearBluetoothState();
|
||||||
|
markDisconnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPrintData(data: Uint8Array, maxAttempts = 3): Promise<void> {
|
||||||
|
const task = printQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await sendPrintDataInternal(data);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('Gagal mengirim data ke printer.');
|
||||||
|
|
||||||
|
clearUsbState();
|
||||||
|
clearBluetoothState();
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await forceReconnect();
|
||||||
|
} catch {
|
||||||
|
// Retry will surface the final error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('Gagal mencetak struk.');
|
||||||
|
});
|
||||||
|
|
||||||
|
printQueue = task.catch(() => undefined);
|
||||||
|
|
||||||
|
await task;
|
||||||
|
}
|
||||||
16
resources/js/lib/thermal-printer/types.ts
Normal file
16
resources/js/lib/thermal-printer/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export type PaperSize = '58mm' | '80mm';
|
||||||
|
|
||||||
|
export type PrinterConnection = 'usb' | 'bluetooth';
|
||||||
|
|
||||||
|
export type PrinterLanguage = 'esc-pos' | 'star-prnt' | 'star-line';
|
||||||
|
|
||||||
|
export type StoredUsbDevice = {
|
||||||
|
vendorId: number | null;
|
||||||
|
productId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoredBluetoothDevice = {
|
||||||
|
id: string;
|
||||||
|
language: PrinterLanguage;
|
||||||
|
codepageMapping: string;
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@ import { Head, Link } from '@inertiajs/vue3';
|
|||||||
import { Plus } from '@lucide/vue';
|
import { Plus } from '@lucide/vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import OrderGroupedTable from '@/components/admin/manage/orders/OrderGroupedTable.vue';
|
import OrderGroupedTable from '@/components/admin/manage/orders/OrderGroupedTable.vue';
|
||||||
|
import ThermalPrinterConnectButton from '@/components/admin/manage/orders/ThermalPrinterConnectButton.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
@ -65,12 +66,16 @@ watch(
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button v-if="can('orders.create')" as-child class="shrink-0 self-start sm:self-center">
|
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
|
||||||
<Link href="/admin/manage/orders/create">
|
<ThermalPrinterConnectButton />
|
||||||
<Plus class="size-4" />
|
|
||||||
Tambah
|
<Button v-if="can('orders.create')" as-child>
|
||||||
</Link>
|
<Link href="/admin/manage/orders/create">
|
||||||
</Button>
|
<Plus class="size-4" />
|
||||||
|
Tambah
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="min-w-0">
|
<Card class="min-w-0">
|
||||||
|
|||||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@ -17,6 +17,7 @@ declare module '@inertiajs/core' {
|
|||||||
export interface InertiaConfig {
|
export interface InertiaConfig {
|
||||||
sharedPageProps: {
|
sharedPageProps: {
|
||||||
name: string;
|
name: string;
|
||||||
|
address: string | null;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
flash: {
|
flash: {
|
||||||
success: string | null;
|
success: string | null;
|
||||||
|
|||||||
61
resources/js/types/point-of-sale.d.ts
vendored
Normal file
61
resources/js/types/point-of-sale.d.ts
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
declare module '@point-of-sale/receipt-printer-encoder' {
|
||||||
|
export default class ReceiptPrinterEncoder {
|
||||||
|
constructor(options?: Record<string, unknown>);
|
||||||
|
|
||||||
|
initialize(): this;
|
||||||
|
align(alignment: 'left' | 'center' | 'right'): this;
|
||||||
|
bold(value?: boolean): this;
|
||||||
|
line(text: string): this;
|
||||||
|
text(text: string): this;
|
||||||
|
newline(count?: number): this;
|
||||||
|
rule(options?: { style?: string; width?: number }): this;
|
||||||
|
table(
|
||||||
|
columns: Array<Record<string, unknown>>,
|
||||||
|
rows: Array<Array<string | ((encoder: ReceiptPrinterEncoder) => ReceiptPrinterEncoder)>>,
|
||||||
|
): this;
|
||||||
|
cut(): this;
|
||||||
|
encode(): Uint8Array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@point-of-sale/webserial-receipt-printer' {
|
||||||
|
export type SerialPrinterDevice = {
|
||||||
|
type: 'serial';
|
||||||
|
vendorId: number | null;
|
||||||
|
productId: number | null;
|
||||||
|
language: string | null;
|
||||||
|
codepageMapping: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class WebSerialReceiptPrinter {
|
||||||
|
constructor(options?: Record<string, unknown>);
|
||||||
|
|
||||||
|
connect(): Promise<void>;
|
||||||
|
reconnect(device: { vendorId?: number | null; productId?: number | null }): Promise<void>;
|
||||||
|
print(data: Uint8Array | number[]): Promise<void>;
|
||||||
|
addEventListener(
|
||||||
|
event: 'connected' | 'disconnected' | 'data',
|
||||||
|
callback: (device?: SerialPrinterDevice) => void,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@point-of-sale/webbluetooth-receipt-printer' {
|
||||||
|
export type BluetoothPrinterDevice = {
|
||||||
|
type: 'bluetooth';
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
language: 'esc-pos' | 'star-prnt';
|
||||||
|
codepageMapping: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class WebBluetoothReceiptPrinter {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
reconnect(device: { id: string }): Promise<void>;
|
||||||
|
print(data: Uint8Array | number[]): Promise<void>;
|
||||||
|
addEventListener(
|
||||||
|
event: 'connected' | 'disconnected',
|
||||||
|
callback: (device?: BluetoothPrinterDevice) => void,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
resources/js/types/thermal-bluetooth.d.ts
vendored
Normal file
57
resources/js/types/thermal-bluetooth.d.ts
vendored
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
interface BluetoothRemoteGATTServer {
|
||||||
|
readonly connected: boolean;
|
||||||
|
connect(): Promise<BluetoothRemoteGATTServer>;
|
||||||
|
disconnect(): void;
|
||||||
|
getPrimaryService(service: string): Promise<BluetoothRemoteGATTService>;
|
||||||
|
getPrimaryServices(): Promise<BluetoothRemoteGATTService[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothRemoteGATTService {
|
||||||
|
readonly uuid: string;
|
||||||
|
getCharacteristic(characteristic: string): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothRemoteGATTCharacteristic {
|
||||||
|
writeValueWithResponse(data: BufferSource): Promise<void>;
|
||||||
|
writeValueWithoutResponse(data: BufferSource): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BluetoothDevice extends EventTarget {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name?: string;
|
||||||
|
readonly gatt?: BluetoothRemoteGATTServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Bluetooth extends EventTarget {
|
||||||
|
requestDevice(options: {
|
||||||
|
acceptAllDevices?: boolean;
|
||||||
|
filters?: Array<Record<string, unknown>>;
|
||||||
|
optionalServices?: string[];
|
||||||
|
}): Promise<BluetoothDevice>;
|
||||||
|
getDevices(): Promise<BluetoothDevice[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SerialPort extends EventTarget {
|
||||||
|
readable: ReadableStream<Uint8Array> | null;
|
||||||
|
writable: WritableStream<Uint8Array> | null;
|
||||||
|
open(options: {
|
||||||
|
baudRate: number;
|
||||||
|
dataBits?: number;
|
||||||
|
stopBits?: number;
|
||||||
|
parity?: string;
|
||||||
|
flowControl?: string;
|
||||||
|
bufferSize?: number;
|
||||||
|
}): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
getInfo(): { usbVendorId?: number; usbProductId?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Serial extends EventTarget {
|
||||||
|
requestPort(): Promise<SerialPort>;
|
||||||
|
getPorts(): Promise<SerialPort[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Navigator {
|
||||||
|
serial?: Serial;
|
||||||
|
bluetooth?: Bluetooth;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user