feat: integrate thermal printer functionality with receipt encoding

- Added @point-of-sale/receipt-printer-encoder dependency to package.json.
- Implemented useThermalPrinter hook for managing Bluetooth and USB printer connections.
- Created encodeOrderReceipt function to format receipt data for printing.
- Enhanced TransactionIndex component to include printer connection options and receipt printing functionality.
- Added dropdown menu for selecting printer connection type (Bluetooth/USB) and printing receipt options (58mm/80mm).
- Updated TransactionCardRow to include print receipt actions.
- Defined TypeScript types for receipt printer encoder and web Bluetooth/USB APIs.
- Configured Vite to optimize dependencies for receipt printer encoder.
This commit is contained in:
Yoga Pangestu 2026-08-07 09:34:11 +07:00
parent add157183b
commit f7a91739f9
10 changed files with 795 additions and 794 deletions

View File

@ -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;
@ -38,6 +39,7 @@ public function share(Request $request): array
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'address' => app(SystemSettings::class)->address ?? '',
'auth' => [ 'auth' => [
'user' => $request->user() 'user' => $request->user()
? tap($request->user()->load('userProfile', 'roles'), function ($user) { ? tap($request->user()->load('userProfile', 'roles'), function ($user) {

829
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@inertiajs/react": "^3.0.0", "@inertiajs/react": "^3.0.0",
"@inertiajs/vite": "^3.0.0", "@inertiajs/vite": "^3.0.0",
"@laravel/passkeys": "^0.2.0", "@laravel/passkeys": "^0.2.0",
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3",
@ -1779,6 +1780,12 @@
} }
} }
}, },
"node_modules/@canvas/image-data": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.1.0.tgz",
"integrity": "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==",
"license": "MIT"
},
"node_modules/@date-fns/tz": { "node_modules/@date-fns/tz": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
@ -2043,37 +2050,6 @@
"integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@emnapi/core": {
"version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
"integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "2.0.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
"integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
"integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1", "version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
@ -2583,27 +2559,6 @@
"node": "^22.20 || ^24.12 || >=25" "node": "^22.20 || ^24.12 || >=25"
} }
}, },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz",
"integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^2.0.0-alpha.3",
"@emnapi/runtime": "^2.0.0-alpha.3"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2648,6 +2603,25 @@
"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/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
@ -4170,150 +4144,6 @@
} }
} }
}, },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
@ -4346,103 +4176,6 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@ -5041,118 +4774,6 @@
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3" "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
@ -5185,51 +4806,6 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
@ -5356,16 +4932,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -5854,216 +5420,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
"integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-android-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
"integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
"integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
"integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
"integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
"integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
"integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
"integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
"integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
"integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-loong64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
"integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
"integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
"integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
"integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
"integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": { "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.12.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
@ -6092,115 +5448,6 @@
"linux" "linux"
] ]
}, },
"node_modules/@unrs/resolver-binding-openharmony-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
"integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
"integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
"integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
"integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
"integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@ -6860,6 +6107,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",
@ -12313,6 +11572,18 @@
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT" "license": "MIT"
}, },
"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",

View File

@ -38,6 +38,7 @@
"@inertiajs/react": "^3.0.0", "@inertiajs/react": "^3.0.0",
"@inertiajs/vite": "^3.0.0", "@inertiajs/vite": "^3.0.0",
"@laravel/passkeys": "^0.2.0", "@laravel/passkeys": "^0.2.0",
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3",

View File

@ -0,0 +1,368 @@
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}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(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,
};
}

View File

@ -1,7 +1,8 @@
import { Head, Link, router } from '@inertiajs/react'; import { Head, Link, router, usePage } from '@inertiajs/react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Plus } from 'lucide-react'; import { Bluetooth, Cable, Plus, Printer, Unplug } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { CardTable } from '@/components/card-table'; import { CardTable } from '@/components/card-table';
import { DatePicker } from '@/components/date-picker'; import { DatePicker } from '@/components/date-picker';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
@ -17,6 +18,13 @@ import {
ComboboxItem, ComboboxItem,
ComboboxList, ComboboxList,
} from '@/components/ui/combobox'; } from '@/components/ui/combobox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -26,6 +34,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { useThermalPrinter, encodeOrderReceipt } from '@/hooks/use-thermal-printer';
import { import {
destroy, destroy,
create as transactionCreate, create as transactionCreate,
@ -89,8 +98,10 @@ export default function TransactionIndex({
filterOptions, filterOptions,
}: Props) { }: Props) {
const { can } = useCan(); const { can } = useCan();
const { name: appName, address: appAddress } = usePage().props as unknown as { name: string; address: string };
const [deleting, setDeleting] = useState<Transaction | null>(null); const [deleting, setDeleting] = useState<Transaction | null>(null);
const expand = useCardTableExpand(true); const expand = useCardTableExpand(true);
const printer = useThermalPrinter();
const pagination = { const pagination = {
current_page: transactions.current_page, current_page: transactions.current_page,
@ -152,6 +163,52 @@ export default function TransactionIndex({
router.patch(transactionUpdateStatus.url(transaction.id), { status }); router.patch(transactionUpdateStatus.url(transaction.id), { status });
} }
function handlePrintReceipt(transaction: Transaction, paperWidth: 58 | 80) {
if (!printer.connected) {
alert('Hubungkan printer terlebih dahulu.');
return;
}
const rupiah = (n: number) => `Rp ${n.toLocaleString('id-ID')}`;
const items = (transaction.order_items ?? []).map((item) => ({
product_name: item.product_variant?.product?.name ?? '-',
variant_name: item.product_variant?.name ?? '-',
quantity: `${Number(item.quantity)}`,
unit_price: rupiah(Number(item.unit_price)),
subtotal: rupiah(Number(item.subtotal)),
}));
const cashierName =
transaction.created_by?.user_profile?.full_name ?? '-';
const data = encodeOrderReceipt(
{
order_number: transaction.order_number,
created_at: format(new Date(transaction.created_at), 'dd/MM/yyyy HH:mm'),
customer_name: transaction.customer?.name ?? null,
cashier_name: cashierName,
items,
subtotal: rupiah(Number(transaction.subtotal)),
discount: rupiah(Number(transaction.discount)),
nego_price: transaction.nego_price != null && Number(transaction.nego_price) > 0
? rupiah(Number(transaction.nego_price))
: null,
total_amount: rupiah(Number(transaction.total_amount)),
notes: transaction.notes ?? null,
},
{
storeName: appName,
storeAddress: appAddress,
paperWidth,
},
);
printer.print(data).catch((err: Error) => {
alert(`Gagal mencetak: ${err.message}`);
});
}
const filterToolbar = ( const filterToolbar = (
<FilterPopover <FilterPopover
open={filterOpen} open={filterOpen}
@ -358,14 +415,76 @@ export default function TransactionIndex({
<PageHeader <PageHeader
title="Transaksi" title="Transaksi"
actions={ actions={
can('orders.create') ? ( <div className="flex items-center gap-2">
<Button asChild> {printer.connected ? (
<Link href={transactionCreate.url()}> <DropdownMenu>
<Plus className="h-4 w-4" /> <DropdownMenuTrigger asChild>
Tambah <Button variant="outline" size="sm">
</Link> <Printer className="h-4 w-4" />
</Button> {printer.printerName}
) : undefined </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={async () => {
const name = await printer.disconnect();
toast.success('Printer diputuskan.');
}}
>
<Unplug className="h-4 w-4" />
Putuskan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Printer className="h-4 w-4" />
Hubungkan Printer
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={async () => {
try {
const name = await printer.connect('bluetooth');
toast.success(`Terhubung ke ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Gagal menghubungkan Bluetooth.');
}
}}
>
<Bluetooth className="h-4 w-4" />
Hubungkan Bluetooth
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
try {
const name = await printer.connect('usb');
toast.success(`Terhubung ke ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Gagal menghubungkan USB.');
}
}}
>
<Cable className="h-4 w-4" />
Hubungkan USB
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{can('orders.create') ? (
<Button asChild>
<Link href={transactionCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
) : undefined}
</div>
} }
/> />
@ -404,6 +523,7 @@ export default function TransactionIndex({
}} }}
onDelete={(t) => setDeleting(t)} onDelete={(t) => setDeleting(t)}
onUpdateStatus={(t, status) => handleUpdateStatus(t, status)} onUpdateStatus={(t, status) => handleUpdateStatus(t, status)}
onPrint={(t, pw) => handlePrintReceipt(t, pw)}
/> />
)} )}
renderSubContent={(transaction) => ( renderSubContent={(transaction) => (

View File

@ -2,10 +2,17 @@ import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import type { PaperWidth } from '@/hooks/use-thermal-printer';
import { formatDateTime, formatNumber } from '@/lib/format'; import { formatDateTime, formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { CheckCircle, ChevronDown, Pencil, Send, Trash2, XCircle } from 'lucide-react'; import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react';
import type { Transaction } from './columns'; import type { Transaction } from './columns';
const STATUS_BADGE_CLASSES: Record<string, string> = { const STATUS_BADGE_CLASSES: Record<string, string> = {
@ -24,6 +31,7 @@ export type TransactionCardRowParams = {
onEdit: (transaction: Transaction) => void; onEdit: (transaction: Transaction) => void;
onDelete: (transaction: Transaction) => void; onDelete: (transaction: Transaction) => void;
onUpdateStatus: (transaction: Transaction, status: string) => void; onUpdateStatus: (transaction: Transaction, status: string) => void;
onPrint: (transaction: Transaction, paperWidth: PaperWidth) => void;
}; };
export function TransactionCardRow({ export function TransactionCardRow({
@ -34,6 +42,7 @@ export function TransactionCardRow({
onEdit, onEdit,
onDelete, onDelete,
onUpdateStatus, onUpdateStatus,
onPrint,
}: TransactionCardRowParams) { }: TransactionCardRowParams) {
const { can } = useCan(); const { can } = useCan();
const items = transaction.order_items ?? []; const items = transaction.order_items ?? [];
@ -51,7 +60,6 @@ export function TransactionCardRow({
); );
const statusBadgeClass = const statusBadgeClass =
STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending; STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending;
console.log('transaction', transaction);
return ( return (
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
@ -188,7 +196,26 @@ console.log('transaction', transaction);
</div> </div>
</div> </div>
{(can('orders.update') || can('orders.delete')) && ( <div className="flex shrink-0 items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title="Cetak Struk">
<Printer className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onPrint(transaction, 58)}
>
Struk 58mm
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onPrint(transaction, 80)}
>
Struk 80mm
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RowActions <RowActions
actions={[ actions={[
...(transaction.status === 'pending' && can('orders.update') ...(transaction.status === 'pending' && can('orders.update')
@ -240,7 +267,7 @@ console.log('transaction', transaction);
]} ]}
wrapperClassName="flex shrink-0 items-center gap-1" wrapperClassName="flex shrink-0 items-center gap-1"
/> />
)} </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -34,7 +34,7 @@ import {
edit as variantEdit, edit as variantEdit,
} from '@/routes/admin/master/products/variants'; } from '@/routes/admin/master/products/variants';
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { Product, ProductVariant } from './columns'; import type { Product, ProductVariant } from './columns';
import { ProductCardRow } from './product-card'; import { ProductCardRow } from './product-card';

View File

@ -0,0 +1,35 @@
declare module '@point-of-sale/receipt-printer-encoder' {
interface ReceiptPrinterEncoderOptions {
language?: string;
codepageMapping?: Record<string, string>;
columns?: number;
}
interface TableOptions {
width: number;
align: 'left' | 'center' | 'right';
}
type TableCell = string | ((encoder: ReceiptPrinterEncoder) => void);
class ReceiptPrinterEncoder {
constructor(options?: ReceiptPrinterEncoderOptions);
initialize(): this;
reset(): this;
align(align: 'left' | 'center' | 'right'): this;
bold(bold?: boolean): this;
underline(underline?: boolean): this;
invert(invert?: boolean): this;
width(width: number): this;
height(height: number): this;
text(text: string): this;
newline(count?: number): this;
line(text: string): this;
table(options: TableOptions[], rows: TableCell[][]): this;
cut(): this;
feed(lines?: number): this;
encode(): Uint8Array;
}
export default ReceiptPrinterEncoder;
}

View File

@ -0,0 +1,174 @@
interface BluetoothDevice {
readonly id: string;
readonly name: string | null;
readonly gatt?: BluetoothRemoteGATTServer;
addEventListener(
type: 'gattserverdisconnected',
listener: EventListener,
): void;
removeEventListener(
type: 'gattserverdisconnected',
listener: EventListener,
): void;
}
interface BluetoothRemoteGATTServer {
readonly device: BluetoothDevice;
readonly connected: boolean;
connect(): Promise<BluetoothRemoteGATTServer>;
disconnect(): void;
getPrimaryService(
service: string,
): Promise<BluetoothRemoteGATTService>;
getPrimaryServices(): Promise<BluetoothRemoteGATTService[]>;
}
interface BluetoothRemoteGATTService {
readonly device: BluetoothDevice;
readonly uuid: string;
getCharacteristic(
characteristic: string,
): Promise<BluetoothCharacteristic>;
getCharacteristics(): Promise<BluetoothCharacteristic[]>;
}
interface BluetoothCharacteristic {
readonly uuid: string;
readonly service: BluetoothRemoteGATTService;
readonly properties: BluetoothCharacteristicProperties;
readValue(): Promise<DataView>;
writeValueWithResponse(value: BufferSource): Promise<void>;
writeValueWithoutResponse(value: BufferSource): Promise<void>;
startNotifications(): Promise<BluetoothCharacteristic>;
stopNotifications(): Promise<BluetoothCharacteristic>;
}
interface BluetoothCharacteristicProperties {
readonly broadcast: boolean;
readonly read: boolean;
readonly writeWithoutResponse: boolean;
readonly write: boolean;
readonly notify: boolean;
readonly indicate: boolean;
readonly authenticatedSignedWrites: boolean;
readonly reliableWrite: boolean;
readonly writableAuxiliaries: boolean;
}
interface BluetoothRequestDeviceOptions {
filters?: BluetoothLEScanFilter[];
optionalServices?: string[];
acceptAllDevices?: boolean;
}
interface BluetoothLEScanFilter {
services?: string[];
name?: string;
namePrefix?: string;
manufacturerData?: BluetoothManufacturerDataFilter[];
serviceData?: BluetoothServiceDataFilter[];
}
interface BluetoothManufacturerDataFilter {
companyIdentifier: number;
dataPrefix?: BufferSource;
mask?: BufferSource;
}
interface BluetoothServiceDataFilter {
service: string;
dataPrefix?: BufferSource;
mask?: BufferSource;
}
interface Navigator {
bluetooth?: {
requestDevice(
options: BluetoothRequestDeviceOptions,
): Promise<BluetoothDevice>;
};
}
interface USBDevice {
readonly vendorId: number;
readonly productId: number;
readonly deviceVersionMajor: number;
readonly deviceVersionMinor: number;
readonly deviceVersionSubminor: number;
readonly manufacturerName: string | null;
readonly productName: string | null;
readonly serialNumber: string | null;
readonly configuration: USBConfiguration | null;
open(): Promise<void>;
close(): Promise<void>;
selectConfiguration(configurationValue: number): Promise<void>;
claimInterface(interfaceNumber: number): Promise<void>;
releaseInterface(interfaceNumber: number): Promise<void>;
transferOut(
endpointNumber: number,
data: BufferSource,
): Promise<USBOutTransferResult>;
transferIn(
endpointNumber: number,
length: number,
): Promise<USBInTransferResult>;
}
interface USBConfiguration {
readonly configurationValue: number;
readonly configurationName: string | null;
readonly interfaces: USBInterface[];
}
interface USBInterface {
readonly interfaceNumber: number;
readonly alternate: USBAlternateInterface;
readonly alternates: USBAlternateInterface[];
}
interface USBAlternateInterface {
readonly alternateSetting: number;
readonly interfaceClass: number;
readonly interfaceSubclass: number;
readonly interfaceProtocol: number;
readonly interfaceName: string | null;
readonly endpoints: USBEndpoint[];
}
interface USBEndpoint {
readonly endpointNumber: number;
readonly direction: 'in' | 'out';
readonly type: 'bulk' | 'interrupt' | 'isochronous' | 'control';
readonly packetSize: number;
}
interface USBOutTransferResult {
readonly bytesWritten: number;
readonly status: 'ok' | 'stall' | 'babble';
}
interface USBInTransferResult {
readonly data: DataView | null;
readonly status: 'ok' | 'stall' | 'babble';
}
interface Navigator {
usb?: {
requestDevice(
options: USBRequestDeviceOptions,
): Promise<USBDevice>;
};
}
interface USBRequestDeviceOptions {
filters: USBDeviceFilter[];
}
interface USBDeviceFilter {
vendorId?: number;
productId?: number;
classCode?: number;
subclassCode?: number;
protocolCode?: number;
serialNumber?: string;
}

View File

@ -8,6 +8,9 @@ import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
optimizeDeps: {
include: ['@point-of-sale/receipt-printer-encoder'],
},
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'], input: ['resources/css/app.css', 'resources/js/app.tsx'],