[Update] print label
Library niimblue tidak digunakan, namun potongan kode tetap disimpan
This commit is contained in:
parent
c8559d63df
commit
8665584567
717
Documentation/NiimbotPrinter-FlowChart.md
Normal file
717
Documentation/NiimbotPrinter-FlowChart.md
Normal file
@ -0,0 +1,717 @@
|
||||
# 📊 Diagram Alur Kerja Printer Niimbot
|
||||
|
||||
## 🔄 Alur Lengkap: Dari UI ke Printer
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User buka halaman Brankas] --> B{Printer sudah<br/>terhubung?}
|
||||
B -->|Tidak| C[Klik 'Hubungkan Printer']
|
||||
B -->|Ya| D[Status: Terhubung]
|
||||
|
||||
C --> E[Modal NiimbotConnector muncul]
|
||||
E --> F{Pilih metode koneksi}
|
||||
F -->|Bluetooth| G[Klik 'Hubungkan Printer']
|
||||
F -->|USB/Serial| G
|
||||
|
||||
G --> H[Browser: Dialog pairing muncul]
|
||||
H --> I[User pilih Niimbot device]
|
||||
I --> J[Library: initClient & connect]
|
||||
|
||||
J --> K{Koneksi<br/>berhasil?}
|
||||
K -->|Tidak| L[Tampilkan error]
|
||||
K -->|Ya| M[Fetch printer info]
|
||||
|
||||
M --> N[Status: Terhubung ✓]
|
||||
N --> D
|
||||
|
||||
D --> O[User klik item di Brankas]
|
||||
O --> P[Modal item muncul]
|
||||
P --> Q[Generate QR Code URL]
|
||||
Q --> R[Tampilkan preview QR]
|
||||
|
||||
R --> S[User klik 'Cetak ke Niimbot']
|
||||
S --> T{Printer<br/>terhubung?}
|
||||
|
||||
T -->|Tidak| U[Alert: Hubungkan printer]
|
||||
U --> C
|
||||
|
||||
T -->|Ya| V[createQRLabelCanvas]
|
||||
V --> W[Load QR image]
|
||||
W --> X[Draw ke canvas:<br/>QR + Kode + Nama + Berat]
|
||||
X --> Y[Convert canvas to DataURL]
|
||||
|
||||
Y --> Z[printQRCode composable]
|
||||
Z --> AA[Stop heartbeat]
|
||||
AA --> AB[Create PrintTask]
|
||||
AB --> AC[ImageEncoder.encodeCanvas]
|
||||
AC --> AD[printTask.printInit]
|
||||
AD --> AE[printTask.printPage]
|
||||
|
||||
AE --> AF{Print<br/>sukses?}
|
||||
AF -->|Tidak| AG[Tampilkan error]
|
||||
AF -->|Ya| AH[printTask.printEnd]
|
||||
|
||||
AH --> AI[Start heartbeat]
|
||||
AI --> AJ[Alert: Berhasil dicetak!]
|
||||
AJ --> AK[Printer cetak label]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arsitektur Komponen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ BrankasList.vue │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ UI Layer │ │
|
||||
│ │ - Tombol "Hubungkan Printer" │ │
|
||||
│ │ - Tombol "Cetak ke Niimbot" │ │
|
||||
│ │ - Modal item dengan QR Code │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Logic Layer │ │
|
||||
│ │ - printQR() → Trigger print │ │
|
||||
│ │ - createQRLabelCanvas() → Generate canvas dengan QR │ │
|
||||
│ │ - handlePrinterConnected() → Event handler │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ useNiimbotPrinter.js (Composable) │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ State Management │ │
|
||||
│ │ - printerClient (reactive) │ │
|
||||
│ │ - connectionState │ │
|
||||
│ │ - isPrinting, printProgress │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Methods │ │
|
||||
│ │ - initClient() → Buat NiimbotClient │ │
|
||||
│ │ - connect() → Hubungkan ke printer │ │
|
||||
│ │ - disconnect() → Putuskan koneksi │ │
|
||||
│ │ - printQRCode() → Print image ke printer │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ @mmote/niimbluelib (Library) │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ NiimbotBluetoothClient / NiimbotSerialClient │ │
|
||||
│ │ - connect() → Web Bluetooth/Serial API │ │
|
||||
│ │ - fetchPrinterInfo() → Get printer metadata │ │
|
||||
│ │ - startHeartbeat() → Maintain connection │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ PrintTask │ │
|
||||
│ │ - newPrintTask() → Buat task print │ │
|
||||
│ │ - printInit() → Inisialisasi print │ │
|
||||
│ │ - printPage() → Kirim data gambar │ │
|
||||
│ │ - waitForFinished() → Tunggu selesai │ │
|
||||
│ │ - printEnd() → Akhiri print │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ ImageEncoder │ │
|
||||
│ │ - encodeCanvas() → Encode canvas ke binary │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Browser Web APIs │
|
||||
│ - Web Bluetooth API (navigator.bluetooth) │
|
||||
│ - Web Serial API (navigator.serial) │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Printer Niimbot (Hardware) │
|
||||
│ - Terima perintah via BLE/USB │
|
||||
│ - Decode binary image data │
|
||||
│ - Print ke label thermal │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 Sequence Diagram: Proses Print
|
||||
|
||||
```
|
||||
User BrankasList useNiimbot niimbluelib Browser API Printer
|
||||
│ │ │ │ │ │
|
||||
│ Klik item │ │ │ │ │
|
||||
├───────────────>│ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ Modal muncul │ │ │ │ │
|
||||
│<───────────────┤ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ Klik 'Cetak' │ │ │ │ │
|
||||
├───────────────>│ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ printQRCode()│ │ │ │
|
||||
│ ├─────────────>│ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ stopHeartbeat() │ │
|
||||
│ │ ├─────────────>│ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ newPrintTask() │ │
|
||||
│ │ ├─────────────>│ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ encodeCanvas() │ │
|
||||
│ │ ├─────────────>│ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ BLE/Serial │ │
|
||||
│ │ │ │ Write Data │ │
|
||||
│ │ │ ├─────────────>│ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ Send Data │
|
||||
│ │ │ │ ├───────────>│
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ Printing │
|
||||
│ │ │ │ │ ........ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ Status ACK │ │
|
||||
│ │ │ │<─────────────┤ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ printEnd() │ │ │
|
||||
│ │ ├─────────────>│ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ startHeartbeat() │ │
|
||||
│ │ ├─────────────>│ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Alert sukses │ │ │ │
|
||||
│ │<─────────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ Label tercetak│ │ │ │ │
|
||||
│<───────────────┴──────────────┴──────────────┴──────────────┴────────────┤
|
||||
│ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Connection Flow Detail
|
||||
|
||||
### Bluetooth Connection
|
||||
```
|
||||
1. User Action
|
||||
└─> Klik "Hubungkan Printer"
|
||||
|
||||
2. initClient('bluetooth')
|
||||
└─> new NiimbotBluetoothClient()
|
||||
|
||||
3. connect()
|
||||
└─> navigator.bluetooth.requestDevice({
|
||||
filters: [{ namePrefix: 'Niimbot' }],
|
||||
optionalServices: [SERVICE_UUID]
|
||||
})
|
||||
|
||||
4. Browser Dialog
|
||||
└─> User pilih device "Niimbot-XXXX"
|
||||
|
||||
5. device.gatt.connect()
|
||||
└─> Establish BLE connection
|
||||
|
||||
6. getPrimaryService(SERVICE_UUID)
|
||||
└─> getCharacteristic(TX_CHAR, RX_CHAR)
|
||||
|
||||
7. Event: 'connect' triggered
|
||||
└─> connectionState = 'connected'
|
||||
|
||||
8. fetchPrinterInfo()
|
||||
└─> Send command: GET_INFO
|
||||
└─> Receive: model, serial, firmware version
|
||||
|
||||
9. startHeartbeat()
|
||||
└─> Kirim ping setiap 1 detik
|
||||
└─> Cek printer masih hidup
|
||||
```
|
||||
|
||||
### USB/Serial Connection
|
||||
```
|
||||
1. User Action
|
||||
└─> Klik "Hubungkan Printer"
|
||||
|
||||
2. initClient('serial')
|
||||
└─> new NiimbotSerialClient()
|
||||
|
||||
3. connect()
|
||||
└─> navigator.serial.requestPort({
|
||||
filters: [{ usbVendorId: 0xXXXX }]
|
||||
})
|
||||
|
||||
4. Browser Dialog
|
||||
└─> User pilih USB device
|
||||
|
||||
5. port.open({ baudRate: 9600 })
|
||||
└─> Establish serial connection
|
||||
|
||||
6. Setup reader/writer streams
|
||||
└─> readable.getReader()
|
||||
└─> writable.getWriter()
|
||||
|
||||
7. Event: 'connect' triggered
|
||||
└─> connectionState = 'connected'
|
||||
|
||||
8. fetchPrinterInfo() & startHeartbeat()
|
||||
└─> (sama dengan Bluetooth)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Image Processing Pipeline
|
||||
|
||||
```
|
||||
QR Code URL (dari API)
|
||||
│
|
||||
▼
|
||||
new Image()
|
||||
│
|
||||
▼
|
||||
img.onload
|
||||
│
|
||||
▼
|
||||
Create Canvas (384x384)
|
||||
│
|
||||
├─> Fill white background
|
||||
│
|
||||
├─> drawImage(qr, x, y, size, size)
|
||||
│
|
||||
├─> fillText(kode_item)
|
||||
│
|
||||
├─> fillText(nama_produk)
|
||||
│
|
||||
├─> fillText(berat)
|
||||
│
|
||||
▼
|
||||
canvas.toDataURL('image/png')
|
||||
│
|
||||
▼
|
||||
loadImageToCanvas(dataUrl)
|
||||
│
|
||||
▼
|
||||
[Optional] applyThreshold(canvas, 140)
|
||||
│
|
||||
├─> getImageData()
|
||||
│
|
||||
├─> for each pixel:
|
||||
│ avg = (r+g+b)/3
|
||||
│ if avg < 140: black
|
||||
│ else: white
|
||||
│
|
||||
├─> putImageData()
|
||||
│
|
||||
▼
|
||||
ImageEncoder.encodeCanvas(canvas, 'top')
|
||||
│
|
||||
├─> Convert to 1-bit bitmap
|
||||
│
|
||||
├─> Rotate if needed (printDirection)
|
||||
│
|
||||
├─> Pack bits: 8 pixels = 1 byte
|
||||
│
|
||||
▼
|
||||
Binary Data (EncodedImage)
|
||||
│
|
||||
▼
|
||||
printTask.printPage(encoded, quantity)
|
||||
│
|
||||
▼
|
||||
Send to Printer via BLE/Serial
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 State Machine Diagram
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ DISCONNECTED │◄────────┐
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
│ connect() │ disconnect()
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ CONNECTING │ │
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
│ success │ error
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ CONNECTED │─────────┤
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
│ printQRCode() │
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ PRINTING │ │
|
||||
│ [progress: X%] │ │
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
│ finished │
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ CONNECTED │─────────┘
|
||||
│ (heartbeat) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Data Flow: QR Code → Printed Label
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 1. DATA SOURCE │
|
||||
│ ─────────────────────────────────────────────────────────│
|
||||
│ selectedItem = { │
|
||||
│ kode_item: "BRN-001", │
|
||||
│ produk: { │
|
||||
│ nama: "Cincin Emas 24K", │
|
||||
│ berat: 5.2 │
|
||||
│ } │
|
||||
│ } │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 2. QR CODE GENERATION (External API) │
|
||||
│ ─────────────────────────────────────────────────────────│
|
||||
│ URL: https://api.qrserver.com/v1/create-qr-code/ │
|
||||
│ ?size=150x150&data=BRN-001 │
|
||||
│ │
|
||||
│ Returns: image/png (base64 or URL) │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 3. CANVAS CREATION │
|
||||
│ ─────────────────────────────────────────────────────────│
|
||||
│ createQRLabelCanvas(qrUrl, item) │
|
||||
│ │
|
||||
│ Canvas 384x384px: │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┐ │ ← QR Code (200x200) │
|
||||
│ │ │ ▓▓ ▓▓▓▓│ │ │
|
||||
│ │ │▓ ▓▓ ▓ │ │ │
|
||||
│ │ │ ▓▓▓ ▓▓▓│ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ BRN-001 │ ← Kode Item (bold 18px) │
|
||||
│ │ Cincin Emas 24K │ ← Nama Produk (14px) │
|
||||
│ │ 5.2g │ ← Berat (12px) │
|
||||
│ │ │ │
|
||||
│ └──────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 4. IMAGE ENCODING │
|
||||
│ ─────────────────────────────────────────────────────────│
|
||||
│ ImageEncoder.encodeCanvas(canvas, 'top') │
|
||||
│ │
|
||||
│ Process: │
|
||||
│ - Convert RGB to grayscale │
|
||||
│ - Apply threshold (< 140 = black, >= 140 = white) │
|
||||
│ - Pack 8 pixels into 1 byte (1-bit bitmap) │
|
||||
│ - Width: 384px = 48 bytes per row │
|
||||
│ - Height: 384 rows │
|
||||
│ - Total: 48 × 384 = 18,432 bytes │
|
||||
│ │
|
||||
│ Output: Uint8Array(18432) │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 5. PRINT PROTOCOL │
|
||||
│ ─────────────────────────────────────────────────────────│
|
||||
│ printTask.printInit() │
|
||||
│ └─> Send: [CMD_INIT, density, labelType, ...] │
|
||||
│ │
|
||||
│ printTask.printPage(encoded, quantity) │
|
||||
│ └─> Send in chunks: │
|
||||
│ [CMD_IMAGE_HEADER, width, height] │
|
||||
│ [CMD_IMAGE_DATA, chunk1...] │
|
||||
│ [CMD_IMAGE_DATA, chunk2...] │
|
||||
│ ... │
|
||||
│ [CMD_IMAGE_END] │
|
||||
│ │
|
||||
│ printTask.waitForFinished() │
|
||||
│ └─> Poll status every 100ms │
|
||||
│ until status = FINISHED │
|
||||
│ │
|
||||
│ printTask.printEnd() │
|
||||
│ └─> Send: [CMD_PRINT_END] │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 6. PHYSICAL OUTPUT │
|
||||
│ ─────────────────────────────────────────────────────────│
|
||||
│ Printer Niimbot: │
|
||||
│ - Receives binary data via BLE/USB │
|
||||
│ - Thermal head heats selected pixels │
|
||||
│ - Paper feeds forward │
|
||||
│ - Label printed with QR + text │
|
||||
│ - Cut (automatic or manual) │
|
||||
│ │
|
||||
│ Result: Physical label 40x40mm dengan QR code │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Component Interaction Map
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ App.vue / Router │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ BrankasList.vue │
|
||||
│ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ Header │ │
|
||||
│ │ - Printer Btn │──┼──┐
|
||||
│ └────────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌────────────────┐ │ │
|
||||
│ │ Item List │ │ │
|
||||
│ │ - Click item │──┼──┼──┐
|
||||
│ └────────────────┘ │ │ │
|
||||
│ │ │ │
|
||||
│ ┌────────────────┐ │ │ │
|
||||
│ │ Item Modal │ │ │ │
|
||||
│ │ - QR Preview │ │ │ │
|
||||
│ │ - Print Btn │──┼──┼──┼──┐
|
||||
│ └────────────────┘ │ │ │ │
|
||||
└──────────────────────┘ │ │ │
|
||||
│ │ │
|
||||
┌─────────────────────────────────────┘ │ │
|
||||
│ │ │
|
||||
▼ │ │
|
||||
┌──────────────────┐ │ │
|
||||
│ NiimbotConnector │ │ │
|
||||
│ .vue │ │ │
|
||||
│ │ │ │
|
||||
│ - Connect UI │◄───────────────────────────┘ │
|
||||
│ - Status display │ │
|
||||
│ - Printer info │ │
|
||||
└────────┬─────────┘ │
|
||||
│ │
|
||||
│ uses │
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────┼───┐
|
||||
│ useNiimbotPrinter.js (Composable) │ │
|
||||
│ │ │
|
||||
│ - State: printerClient, connectionState, etc │◄──┘
|
||||
│ - Methods: connect(), disconnect(), printQR() │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Event Listeners Setup │ │
|
||||
│ │ - on('connect') │ │
|
||||
│ │ - on('disconnect') │ │
|
||||
│ │ - on('printprogress') │ │
|
||||
│ │ - on('heartbeat') │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└──────────────────┬───────────────────────────────┘
|
||||
│ imports
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ @mmote/niimbluelib │
|
||||
│ │
|
||||
│ - NiimbotBluetoothClient │
|
||||
│ - NiimbotSerialClient │
|
||||
│ - ImageEncoder │
|
||||
│ - PrintTask abstraction │
|
||||
│ - Utils │
|
||||
└──────────────────┬─────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Browser APIs │
|
||||
│ - navigator.bluetooth (Web Bluetooth API) │
|
||||
│ - navigator.serial (Web Serial API) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Timeline
|
||||
|
||||
```
|
||||
Event Timeline (typical print job):
|
||||
|
||||
0ms │ User clicks "Cetak ke Niimbot"
|
||||
│
|
||||
10ms │ Check printer connection
|
||||
│ └─> Already connected ✓
|
||||
│
|
||||
20ms │ createQRLabelCanvas() start
|
||||
│ └─> Load QR image
|
||||
│
|
||||
150ms │ Image loaded, draw to canvas
|
||||
│ └─> Draw QR, text, etc
|
||||
│
|
||||
170ms │ canvas.toDataURL()
|
||||
│
|
||||
200ms │ printQRCode() called
|
||||
│ └─> stopHeartbeat()
|
||||
│
|
||||
220ms │ newPrintTask()
|
||||
│
|
||||
240ms │ ImageEncoder.encodeCanvas()
|
||||
│ └─> RGB → grayscale
|
||||
│ └─> Threshold
|
||||
│ └─> Pack to 1-bit
|
||||
│
|
||||
450ms │ Encoding complete (18KB data)
|
||||
│
|
||||
470ms │ printTask.printInit()
|
||||
│ └─> Send init command
|
||||
│
|
||||
490ms │ ACK received
|
||||
│
|
||||
500ms │ printTask.printPage()
|
||||
│ └─> Send image header
|
||||
│
|
||||
520ms │ Send data chunk 1/10
|
||||
540ms │ Send data chunk 2/10
|
||||
... │ ...
|
||||
740ms │ Send data chunk 10/10
|
||||
│
|
||||
750ms │ All data sent
|
||||
│
|
||||
780ms │ waitForFinished() polling start
|
||||
│
|
||||
800ms │ Status: PRINTING (0%)
|
||||
900ms │ Status: PRINTING (25%)
|
||||
1000ms │ Status: PRINTING (50%)
|
||||
1100ms │ Status: PRINTING (75%)
|
||||
1200ms │ Status: PRINTING (100%)
|
||||
│
|
||||
1220ms │ Status: FINISHED
|
||||
│
|
||||
1230ms │ printTask.printEnd()
|
||||
│
|
||||
1250ms │ startHeartbeat()
|
||||
│
|
||||
1260ms │ Alert: "QR Code berhasil dicetak!"
|
||||
│
|
||||
Total: ~1.3 seconds dari klik hingga selesai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security & Privacy
|
||||
|
||||
```
|
||||
Data Flow Security:
|
||||
|
||||
┌──────────────────┐
|
||||
│ User's Browser │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
│ HTTPS (encrypted)
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ QR API Server │ ← External: api.qrserver.com
|
||||
└────────┬─────────┘ (Kirim kode_item only)
|
||||
│
|
||||
│ Returns image URL
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ User's Browser │
|
||||
│ - Generate │
|
||||
│ canvas │
|
||||
│ - Encode image │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
│ BLE/USB (direct, tidak via internet)
|
||||
│ Encrypted jika BLE
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Niimbot Printer │ ← Local device, tidak terkoneksi internet
|
||||
└──────────────────┘
|
||||
|
||||
Privacy Notes:
|
||||
- Data tidak lewat server backend aplikasi
|
||||
- QR Code dibuat real-time di browser
|
||||
- Gambar langsung dikirim ke printer lokal
|
||||
- Tidak ada logging data item ke cloud
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Error Handling Flow
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ User Action │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Try: connect() │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ Success │ │ Error │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌────────────────┐
|
||||
│ │ Catch error │
|
||||
│ │ - Log to │
|
||||
│ │ console │
|
||||
│ │ - Set error │
|
||||
│ │ message │
|
||||
│ │ - Alert user │
|
||||
│ └────────┬───────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌────────────────┐
|
||||
│ │ connectionState│
|
||||
│ │ = 'disconnected│
|
||||
│ └────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ fetchPrinterInfo│
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
┌─────▼─────┐ ┌────▼────┐
|
||||
│ Success │ │ Error │
|
||||
└─────┬─────┘ └────┬────┘
|
||||
│ │
|
||||
│ └──> Log & continue (non-critical)
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ startHeartbeat()│
|
||||
└─────────┬───────┘
|
||||
│
|
||||
└──> Connected & Ready
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Semua diagram ini membantu memahami bagaimana sistem bekerja end-to-end! 🎉
|
||||
373
Documentation/NiimbotPrinter.md
Normal file
373
Documentation/NiimbotPrinter.md
Normal file
@ -0,0 +1,373 @@
|
||||
# 🖨️ Dokumentasi Integrasi Printer Niimbot
|
||||
|
||||
## 📋 Ringkasan
|
||||
|
||||
Aplikasi kasir sekarang mendukung pencetakan QR Code langsung ke printer Niimbot menggunakan library `@mmote/niimbluelib` yang diadaptasi dari proyek niimblue.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Teknologi & Library
|
||||
|
||||
### Library Utama
|
||||
- **@mmote/niimbluelib** - Library JavaScript untuk komunikasi dengan printer Niimbot
|
||||
- Mendukung Web Bluetooth API
|
||||
- Mendukung Web Serial API (USB)
|
||||
- Encoding gambar ke format printer
|
||||
|
||||
### Browser Support
|
||||
| Browser | Bluetooth | USB (Serial) |
|
||||
|---------|-----------|--------------|
|
||||
| Chrome/Edge (Desktop) | ✅ | ✅ |
|
||||
| Chrome (Android) | ✅ | ❌ |
|
||||
| Firefox | ❌ | ❌ |
|
||||
| Safari | ❌ | ❌ |
|
||||
|
||||
**Rekomendasi:** Gunakan Chrome atau Edge versi terbaru
|
||||
|
||||
---
|
||||
|
||||
## 📁 Struktur File
|
||||
|
||||
```
|
||||
Kasir/resources/js/
|
||||
├── composables/
|
||||
│ └── useNiimbotPrinter.js # Composable untuk koneksi & print
|
||||
├── components/
|
||||
│ ├── NiimbotConnector.vue # Modal untuk koneksi printer
|
||||
│ └── BrankasList.vue # Modifikasi: tambah fitur print Niimbot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Cara Kerja
|
||||
|
||||
### 1️⃣ Alur Koneksi Printer
|
||||
|
||||
```
|
||||
User klik "Hubungkan Printer"
|
||||
→ Modal NiimbotConnector muncul
|
||||
→ Pilih metode: Bluetooth / USB
|
||||
→ Browser munculkan dialog pairing
|
||||
→ User pilih printer Niimbot
|
||||
→ Koneksi terjalin
|
||||
→ Fetch info printer
|
||||
→ Status: "Terhubung"
|
||||
```
|
||||
|
||||
### 2️⃣ Alur Print QR Code
|
||||
|
||||
```
|
||||
User klik item di Brankas
|
||||
→ Modal popup dengan QR Code
|
||||
→ User klik "Cetak ke Niimbot"
|
||||
→ Cek koneksi printer:
|
||||
- Jika belum terhubung → tampilkan modal koneksi
|
||||
- Jika sudah terhubung → lanjut
|
||||
→ Generate canvas dengan QR + info item
|
||||
→ Encode canvas ke format printer
|
||||
→ Kirim ke printer via library
|
||||
→ Status: "Mencetak... X%"
|
||||
→ Selesai
|
||||
```
|
||||
|
||||
### 3️⃣ Komponen Kunci
|
||||
|
||||
#### `useNiimbotPrinter.js` (Composable)
|
||||
```javascript
|
||||
// State management
|
||||
- printerClient // Instance NiimbotClient
|
||||
- connectionState // 'disconnected' | 'connecting' | 'connected'
|
||||
- connectedPrinterName // Nama printer yang terhubung
|
||||
- printerInfo // Info printer (model, serial, dll)
|
||||
- isPrinting // Status sedang print
|
||||
- printProgress // Progress print (0-100)
|
||||
|
||||
// Methods
|
||||
- initClient(type) // Init client (bluetooth/serial)
|
||||
- connect() // Hubungkan ke printer
|
||||
- disconnect() // Putuskan koneksi
|
||||
- printQRCode(dataUrl, options) // Print QR code image
|
||||
```
|
||||
|
||||
#### `NiimbotConnector.vue` (Component)
|
||||
- Modal untuk koneksi/disconnect printer
|
||||
- Pilih metode: Bluetooth atau USB
|
||||
- Tampilkan status koneksi
|
||||
- Tampilkan info printer detail
|
||||
|
||||
#### `BrankasList.vue` (Modified)
|
||||
- Tombol "Hubungkan Printer" di header
|
||||
- Tombol "Cetak ke Niimbot" di modal item
|
||||
- Tombol "Browser" sebagai fallback
|
||||
- Alert sukses/error
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Instalasi
|
||||
|
||||
### 1. Install Library
|
||||
```bash
|
||||
cd "c:\Data\Magang\Toko perhiasan\Kasir"
|
||||
npm install @mmote/niimbluelib
|
||||
```
|
||||
|
||||
### 2. Files Sudah Dibuat
|
||||
- ✅ `resources/js/composables/useNiimbotPrinter.js`
|
||||
- ✅ `resources/js/components/NiimbotConnector.vue`
|
||||
- ✅ `resources/js/components/BrankasList.vue` (modified)
|
||||
|
||||
### 3. Build Assets
|
||||
```bash
|
||||
npm run build
|
||||
# atau untuk development
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Cara Penggunaan
|
||||
|
||||
### A. Menghubungkan Printer
|
||||
|
||||
1. **Persiapan Printer:**
|
||||
- Nyalakan printer Niimbot
|
||||
- Pastikan kertas label sudah terpasang
|
||||
- Untuk Bluetooth: Aktifkan mode pairing (biasanya tahan tombol power)
|
||||
|
||||
2. **Di Aplikasi:**
|
||||
- Buka halaman Brankas
|
||||
- Klik tombol "Hubungkan Printer" (di kanan atas)
|
||||
- Pilih metode koneksi (Bluetooth/USB)
|
||||
- Klik "Hubungkan Printer"
|
||||
- Browser akan menampilkan dialog - pilih printer Niimbot Anda
|
||||
- Tunggu hingga status berubah "Terhubung"
|
||||
|
||||
### B. Mencetak QR Code
|
||||
|
||||
1. **Pilih Item:**
|
||||
- Klik item yang ingin dicetak QR-nya
|
||||
- Modal akan muncul dengan QR Code
|
||||
|
||||
2. **Cetak:**
|
||||
- Klik tombol "Cetak ke Niimbot"
|
||||
- Jika printer belum terhubung, akan muncul peringatan
|
||||
- Progress print akan ditampilkan (Mencetak... X%)
|
||||
- Tunggu hingga selesai
|
||||
|
||||
3. **Alternatif:**
|
||||
- Klik tombol "Browser" untuk print menggunakan printer biasa
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Konfigurasi Print
|
||||
|
||||
Di file `BrankasList.vue`, fungsi `printQR()`:
|
||||
|
||||
```javascript
|
||||
await printQRCode(imageDataUrl, {
|
||||
density: 3, // Kepadatan tinta (1-5, default: 3)
|
||||
quantity: 1, // Jumlah cetakan (default: 1)
|
||||
labelType: 1, // Tipe label:
|
||||
// 1 = WithGaps (label dengan jarak)
|
||||
// 2 = Continuous (tanpa jarak)
|
||||
// 3 = WithHoles (dengan lubang)
|
||||
printTaskName: 'D110' // Model printer (D110, B21, B1, dll)
|
||||
// Auto-detect jika tidak cocok
|
||||
});
|
||||
```
|
||||
|
||||
### Ukuran Label
|
||||
Di fungsi `createQRLabelCanvas()`:
|
||||
```javascript
|
||||
const labelWidth = 384; // pixel (40mm @ 240dpi)
|
||||
const labelHeight = 384; // pixel
|
||||
```
|
||||
|
||||
**Sesuaikan dengan ukuran label Anda:**
|
||||
- 30mm x 30mm ≈ 288 x 288 px
|
||||
- 40mm x 40mm ≈ 384 x 384 px
|
||||
- 50mm x 30mm ≈ 480 x 288 px
|
||||
|
||||
Rumus: `mm × (dpi / 25.4)`
|
||||
- DPI printer Niimbot umumnya: 203 atau 240
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### 1. "Browser tidak mendukung koneksi printer"
|
||||
**Solusi:**
|
||||
- Gunakan Chrome atau Edge versi terbaru
|
||||
- Update browser ke versi terkini
|
||||
- Pastikan menggunakan HTTPS (Web Bluetooth hanya jalan di HTTPS)
|
||||
|
||||
### 2. "Gagal terhubung ke printer"
|
||||
**Solusi:**
|
||||
- Pastikan printer sudah dinyalakan
|
||||
- Pastikan Bluetooth/USB aktif
|
||||
- Coba matikan dan nyalakan ulang printer
|
||||
- Untuk Bluetooth: pastikan tidak paired ke device lain
|
||||
- Coba refresh halaman dan ulangi koneksi
|
||||
|
||||
### 3. "Printer terhubung tapi tidak print"
|
||||
**Solusi:**
|
||||
- Periksa kertas label sudah terpasang dengan benar
|
||||
- Coba disconnect dan connect ulang
|
||||
- Periksa battery printer (untuk model portable)
|
||||
- Cek ukuran canvas sesuai dengan ukuran label
|
||||
|
||||
### 4. "Print hasil blur/tidak jelas"
|
||||
**Solusi:**
|
||||
- Tingkatkan `density` (misal dari 3 ke 4)
|
||||
- Pastikan threshold image sudah optimal
|
||||
- Ukuran QR Code jangan terlalu kecil (min 150x150px)
|
||||
|
||||
### 5. "Error: printTaskName not compatible"
|
||||
**Solusi:**
|
||||
- Ganti `printTaskName` sesuai model printer:
|
||||
- D110: `'D110'`
|
||||
- B21: `'B21'`
|
||||
- B1: `'B1'`
|
||||
- Atau hapus parameter, biarkan auto-detect
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Enable Console Logs
|
||||
Library sudah dilengkapi logging:
|
||||
```javascript
|
||||
// Di browser console akan muncul:
|
||||
>> Packet sent: [hex bytes]
|
||||
<< Packet received: [hex bytes]
|
||||
Printer connected: { deviceName: "Niimbot-XXXX" }
|
||||
Printer info fetched: { model: "D110", ... }
|
||||
```
|
||||
|
||||
### Test Print Canvas
|
||||
Tambahkan debug preview sebelum print:
|
||||
```javascript
|
||||
// Di createQRLabelCanvas, sebelum resolve(canvas):
|
||||
document.body.appendChild(canvas); // Tampilkan canvas di halaman
|
||||
canvas.style.border = '1px solid red';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Referensi API
|
||||
|
||||
### useNiimbotPrinter Composable
|
||||
|
||||
#### State (Reactive)
|
||||
```javascript
|
||||
const {
|
||||
printerClient, // NiimbotClient instance
|
||||
connectionState, // 'disconnected' | 'connecting' | 'connected'
|
||||
connectedPrinterName, // string
|
||||
printerInfo, // object { model, serial, ... }
|
||||
printerMeta, // object { densityMin, densityMax, ... }
|
||||
heartbeatData, // object { powerLevel, ... }
|
||||
isPrinting, // boolean
|
||||
printProgress, // number (0-100)
|
||||
|
||||
// Computed
|
||||
isConnected, // boolean
|
||||
isDisconnected, // boolean
|
||||
featureSupport, // { webBluetooth, webSerial, ... }
|
||||
} = useNiimbotPrinter();
|
||||
```
|
||||
|
||||
#### Methods
|
||||
```javascript
|
||||
// Init client
|
||||
initClient(type: 'bluetooth' | 'serial'): void
|
||||
|
||||
// Connect/Disconnect
|
||||
connect(): Promise<void>
|
||||
disconnect(): void
|
||||
|
||||
// Print
|
||||
printQRCode(
|
||||
imageDataUrl: string,
|
||||
options?: {
|
||||
density?: number, // 1-5
|
||||
quantity?: number, // jumlah print
|
||||
labelType?: number, // 1=WithGaps, 2=Continuous, 3=WithHoles
|
||||
printTaskName?: string // 'D110', 'B21', dll
|
||||
}
|
||||
): Promise<{ success: boolean, message: string }>
|
||||
|
||||
// Utils
|
||||
loadImageToCanvas(dataUrl: string): Promise<HTMLCanvasElement>
|
||||
applyThreshold(canvas: HTMLCanvasElement, threshold?: number): HTMLCanvasElement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Ubah Desain Label
|
||||
Edit fungsi `createQRLabelCanvas()` di `BrankasList.vue`:
|
||||
|
||||
```javascript
|
||||
// Contoh: Tambah logo toko
|
||||
const logo = new Image();
|
||||
logo.src = '/path/to/logo.png';
|
||||
logo.onload = () => {
|
||||
ctx.drawImage(logo, 10, 10, 50, 50);
|
||||
};
|
||||
|
||||
// Contoh: Tambah border
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(5, 5, labelWidth - 10, labelHeight - 10);
|
||||
|
||||
// Contoh: Font custom
|
||||
ctx.font = 'bold 20px "Courier New"';
|
||||
```
|
||||
|
||||
### Tambah Barcode
|
||||
Install library barcode:
|
||||
```bash
|
||||
npm install jsbarcode
|
||||
```
|
||||
|
||||
Implementasi:
|
||||
```javascript
|
||||
import JsBarcode from 'jsbarcode';
|
||||
|
||||
const barcodeCanvas = document.createElement('canvas');
|
||||
JsBarcode(barcodeCanvas, item.kode_item, {
|
||||
format: 'CODE128',
|
||||
width: 2,
|
||||
height: 40,
|
||||
});
|
||||
ctx.drawImage(barcodeCanvas, x, y);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Catatan Penting
|
||||
|
||||
1. **HTTPS Required**: Web Bluetooth & Web Serial hanya bekerja di HTTPS (kecuali localhost)
|
||||
2. **User Gesture**: Koneksi harus dipicu oleh user action (klik button), tidak bisa otomatis on load
|
||||
3. **One Printer**: Satu browser session hanya bisa terhubung ke 1 printer
|
||||
4. **Battery**: Printer portable akan disconnect otomatis jika battery rendah
|
||||
5. **Label Size**: Sesuaikan ukuran canvas dengan ukuran label fisik untuk hasil optimal
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
Jika ada masalah:
|
||||
1. Periksa console browser (F12 → Console)
|
||||
2. Periksa kompatibilitas browser
|
||||
3. Periksa dokumentasi printer Niimbot Anda
|
||||
4. Issue library: https://github.com/MultiMote/niimbluelib
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
Menggunakan library `@mmote/niimbluelib` yang bersifat open-source.
|
||||
Pastikan mematuhi lisensi library saat deploy production.
|
||||
BIN
driver/NiimbotPrinterDriverInstall_3.0.0.5.exe
Normal file
BIN
driver/NiimbotPrinterDriverInstall_3.0.0.5.exe
Normal file
Binary file not shown.
24
package-lock.json
generated
24
package-lock.json
generated
@ -1874,9 +1874,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -3700,14 +3700,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2"
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@ -3805,9 +3805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||
"version": "7.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
|
||||
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -3816,7 +3816,7 @@
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
||||
@ -7,11 +7,13 @@
|
||||
<div v-else>
|
||||
<!-- Alert Section -->
|
||||
<div class="mb-4" v-if="alert">
|
||||
<div v-if="alert.error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<div v-if="alert.error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4"
|
||||
role="alert">
|
||||
<strong class="font-bold">Error!</strong>
|
||||
<span class="block sm:inline">{{ alert.error }}</span>
|
||||
</div>
|
||||
<div v-if="alert.success" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<div v-if="alert.success"
|
||||
class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<strong class="font-bold">Success!</strong>
|
||||
<span class="block sm:inline">{{ alert.success }}</span>
|
||||
</div>
|
||||
@ -46,8 +48,7 @@
|
||||
@click="openMovePopup(item)">
|
||||
<!-- Gambar & Info Produk -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img v-if="item.produk?.foto?.length" :src="item.produk?.foto[0].url"
|
||||
class="size-12 object-cover rounded"
|
||||
<img v-if="item.produk?.foto?.length" :src="item.produk?.foto[0].url" class="size-12 object-cover rounded"
|
||||
@error="handleImageError" />
|
||||
<div class="size-12 bg-gray-200 rounded flex items-center justify-center" v-else>
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
@ -64,8 +65,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Modal Pindah Nampan -->
|
||||
<div v-if="isPopupVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
|
||||
<div v-if="isPopupVisible"
|
||||
class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-2 border border-C rounded-lg">
|
||||
@ -85,9 +88,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Tombol Cetak -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition">
|
||||
<i class="fas fa-print mr-2"></i>Cetak
|
||||
<div class="flex justify-center gap-2 mb-4">
|
||||
<button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition"
|
||||
title="Cetak menggunakan printer browser">
|
||||
<i class="fas fa-print mr-2"></i>
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -107,13 +112,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Tombol -->
|
||||
<!-- Tombol -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="closePopup" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="closePopup"
|
||||
class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
||||
Batal
|
||||
</button>
|
||||
|
||||
<button @click="showDeleteConfirm = true"
|
||||
<button @click="showDeleteConfirm = true"
|
||||
class="px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600 transition flex items-center">
|
||||
<i class="fas fa-trash mr-2"></i>Hapus
|
||||
</button>
|
||||
@ -124,24 +129,20 @@
|
||||
<div v-if="isMoving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{{ isMoving ? 'Memindahkan...' : 'Pindahkan' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal Konfirmasi Hapus -->
|
||||
<ConfirmDeleteModal
|
||||
:isOpen="showDeleteConfirm"
|
||||
title="Konfirmasi Hapus Item"
|
||||
message="Apakah kamu yakin ingin menghapus item ini?"
|
||||
confirmText="Ya, Hapus"
|
||||
cancelText="Batal"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
<ConfirmDeleteModal :isOpen="showDeleteConfirm" title="Konfirmasi Hapus Item"
|
||||
message="Apakah kamu yakin ingin menghapus item ini?" confirmText="Ya, Hapus" cancelText="Batal"
|
||||
@confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
|
||||
<!-- Confirm Modal untuk aksi berbahaya (jika diperlukan di masa depan) -->
|
||||
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-xl shadow-lg max-w-md w-full p-6 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
|
||||
<div v-if="isConfirmModalVisible"
|
||||
class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-lg max-w-md w-full p-6 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-red-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
@ -154,10 +155,12 @@
|
||||
<p class="text-sm text-gray-500" v-html="confirmModalMessage"></p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="closeConfirmModal" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
||||
<button @click="closeConfirmModal"
|
||||
class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button @click="handleConfirmAction" class="px-4 py-2 rounded bg-red-500 hover:bg-red-600 text-white transition">
|
||||
<button @click="handleConfirmAction"
|
||||
class="px-4 py-2 rounded bg-red-500 hover:bg-red-600 text-white transition">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
@ -181,7 +184,6 @@ const props = defineProps({
|
||||
const items = ref([]);
|
||||
const trays = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const alert = ref(null);
|
||||
const timer = ref(null);
|
||||
|
||||
@ -264,7 +266,6 @@ const confirmDelete = async () => {
|
||||
// Auto hide alert
|
||||
clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Gagal menghapus item:", err.response?.data || err);
|
||||
alert.value = { error: err.response?.data?.message || "Gagal menghapus item. Silakan coba lagi." };
|
||||
@ -275,7 +276,6 @@ const confirmDelete = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteConfirm.value = false;
|
||||
};
|
||||
@ -308,7 +308,6 @@ const saveMove = async () => {
|
||||
// Auto hide alert
|
||||
clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Gagal memindahkan item:", err.response?.data || err);
|
||||
errorMove.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
|
||||
@ -331,7 +330,7 @@ const handleConfirmAction = async () => {
|
||||
closeConfirmModal();
|
||||
};
|
||||
|
||||
// Fungsi utilitas
|
||||
// Print QR menggunakan browser
|
||||
const printQR = () => {
|
||||
if (qrCodeUrl.value && selectedItem.value) {
|
||||
const printWindow = window.open('', '_blank');
|
||||
@ -342,35 +341,80 @@ const printQR = () => {
|
||||
<head>
|
||||
<title>Print QR Code - ${itemCode}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: 38mm 25mm;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
width: 38mm;
|
||||
height: 25mm;
|
||||
padding: 2mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
border: 2px solid #ccc;
|
||||
padding: 20px;
|
||||
display: inline-block;
|
||||
margin: 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 18mm;
|
||||
height: 18mm;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-weight: bold;
|
||||
font-size: 8pt;
|
||||
margin-bottom: 0.5mm;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 34mm;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 6pt;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 34mm;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-weight {
|
||||
font-size: 6pt;
|
||||
color: #666;
|
||||
margin-top: 0.5mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.item-info {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="qr-container">
|
||||
<img id="qr-img" class="qr-img" src="${qrCodeUrl.value}" alt="QR Code" />
|
||||
<div class="item-info">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">${itemCode}</div>
|
||||
<div>${selectedItem.value.produk?.nama || ''}</div>
|
||||
<div style="color: #666; margin-top: 5px;">${selectedItem.value.produk?.berat || ''}g</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -386,7 +430,6 @@ const printQR = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.style.display = 'none';
|
||||
};
|
||||
@ -421,9 +464,17 @@ onMounted(refreshData);
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
class="flex-1 px-6 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-lg transition-colors">
|
||||
Selesai
|
||||
</button>
|
||||
<button @click="printItem"
|
||||
<button @click="printQR"
|
||||
class="flex-1 px-6 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors">
|
||||
<i class="fas fa-print mr-1"></i>Print
|
||||
</button>
|
||||
@ -175,45 +175,90 @@ const addNewItem = () => {
|
||||
};
|
||||
|
||||
// Fungsi print berdasarkan logika dari brankas list
|
||||
const printItem = () => {
|
||||
const printQR = () => {
|
||||
if (qrCodeUrl.value && createdItem.value && props.product) {
|
||||
const printWindow = window.open('', '_blank');
|
||||
const itemCode = createdItem.value.kode_item || createdItem.value.id;
|
||||
const itemCode = createdItem.value.kode_item;
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Print QR Code - ${itemCode}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: 38mm 25mm;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
width: 38mm;
|
||||
height: 25mm;
|
||||
padding: 2mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
border: 2px solid #ccc;
|
||||
padding: 20px;
|
||||
display: inline-block;
|
||||
margin: 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 18mm;
|
||||
height: 18mm;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-weight: bold;
|
||||
font-size: 8pt;
|
||||
margin-bottom: 0.5mm;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 34mm;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 6pt;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 34mm;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-weight {
|
||||
font-size: 6pt;
|
||||
color: #666;
|
||||
margin-top: 0.5mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.item-info {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="qr-container">
|
||||
<img id="qr-img" class="qr-img" src="${qrCodeUrl.value}" alt="QR Code" />
|
||||
<div class="item-info">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">${itemCode}</div>
|
||||
<div>${props.product.nama}</div>
|
||||
<div style="color: #666; margin-top: 5px;">${props.product.berat}g</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
189
resources/js/components/NiimbotConnector.vue
Normal file
189
resources/js/components/NiimbotConnector.vue
Normal file
@ -0,0 +1,189 @@
|
||||
<!-- Komponen ini digunakan jika menggunakan library @mmote/niimbluelib -->
|
||||
<template>
|
||||
<div v-if="show" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-xl shadow-lg max-w-md w-full p-6 relative transform transition-all duration-300">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-D">
|
||||
<i class="fas fa-print mr-2"></i>
|
||||
Koneksi Printer Niimbot
|
||||
</h3>
|
||||
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="mb-4">
|
||||
<div v-if="isConnected" class="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div>
|
||||
<div class="font-medium text-green-900">Terhubung</div>
|
||||
<div class="text-sm text-green-700">{{ connectedPrinterName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="handleDisconnect" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition text-sm">
|
||||
<i class="fas fa-power-off mr-1"></i>
|
||||
Putus
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="connectionState === 'connecting'" class="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
<span class="text-blue-900">Menghubungkan...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div class="text-gray-600 text-sm mb-2">Status: Tidak terhubung</div>
|
||||
|
||||
<!-- Connection Type Selector -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
v-if="featureSupport.webBluetooth"
|
||||
@click="connectionType = 'bluetooth'"
|
||||
:class="[
|
||||
'flex-1 px-3 py-2 rounded border transition text-sm',
|
||||
connectionType === 'bluetooth'
|
||||
? 'bg-D text-white border-D'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
]">
|
||||
<i class="fab fa-bluetooth-b mr-1"></i>
|
||||
Bluetooth
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="featureSupport.webSerial"
|
||||
@click="connectionType = 'serial'"
|
||||
:class="[
|
||||
'flex-1 px-3 py-2 rounded border transition text-sm',
|
||||
connectionType === 'serial'
|
||||
? 'bg-D text-white border-D'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
]">
|
||||
<i class="fas fa-usb mr-1"></i>
|
||||
USB
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connect Button -->
|
||||
<button
|
||||
@click="handleConnect"
|
||||
:disabled="!canConnect"
|
||||
:class="[
|
||||
'w-full px-4 py-2 rounded transition flex items-center justify-center',
|
||||
canConnect
|
||||
? 'bg-D text-white hover:bg-D/80'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
]">
|
||||
<i class="fas fa-power mr-2"></i>
|
||||
Hubungkan Printer
|
||||
</button>
|
||||
|
||||
<div v-if="!canConnect" class="text-xs text-red-500 mt-2 text-center">
|
||||
Browser Anda tidak mendukung koneksi printer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer Info (when connected) -->
|
||||
<div v-if="isConnected && printerInfo" class="mb-4">
|
||||
<button
|
||||
@click="showDetails = !showDetails"
|
||||
class="w-full flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 transition">
|
||||
<span class="text-sm font-medium text-D">Detail Printer</span>
|
||||
<i :class="['fas transition-transform', showDetails ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
|
||||
</button>
|
||||
|
||||
<div v-if="showDetails" class="mt-2 p-3 bg-gray-50 rounded text-sm">
|
||||
<div v-for="(value, key) in printerInfo" :key="key" class="flex justify-between py-1">
|
||||
<span class="text-gray-600">{{ key }}:</span>
|
||||
<span class="font-medium text-D">{{ value || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="error" class="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="text-xs text-gray-500 p-3 bg-gray-50 rounded">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<strong>Tips:</strong> Pastikan printer Niimbot sudah dihidupkan dan mode pairing aktif (untuk Bluetooth) atau terhubung via USB.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useNiimbotPrinter } from '../composables/useNiimbotPrinter';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'connected', 'disconnected']);
|
||||
|
||||
// Use Niimbot composable
|
||||
const {
|
||||
connectionState,
|
||||
connectedPrinterName,
|
||||
printerInfo,
|
||||
printerMeta,
|
||||
isConnected,
|
||||
isDisconnected,
|
||||
featureSupport,
|
||||
connectionType,
|
||||
initClient,
|
||||
connect,
|
||||
disconnect,
|
||||
} = useNiimbotPrinter();
|
||||
|
||||
// Local state
|
||||
const error = ref('');
|
||||
const showDetails = ref(false);
|
||||
|
||||
// Computed
|
||||
const canConnect = computed(() => {
|
||||
return featureSupport.value.webBluetooth || featureSupport.value.webSerial;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleConnect = async () => {
|
||||
error.value = '';
|
||||
try {
|
||||
await connect();
|
||||
emit('connected');
|
||||
} catch (err) {
|
||||
console.error('Connection error:', err);
|
||||
error.value = err.message || 'Gagal terhubung ke printer. Pastikan printer sudah dinyalakan dan dalam mode pairing.';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
disconnect();
|
||||
emit('disconnected');
|
||||
error.value = '';
|
||||
};
|
||||
|
||||
// Watch connection state changes
|
||||
watch(isConnected, (newVal) => {
|
||||
if (newVal) {
|
||||
error.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@ -186,35 +186,80 @@ const printQR = () => {
|
||||
<head>
|
||||
<title>Print QR Code - ${itemCode}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: 38mm 25mm;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
width: 38mm;
|
||||
height: 25mm;
|
||||
padding: 2mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
border: 2px solid #ccc;
|
||||
padding: 20px;
|
||||
display: inline-block;
|
||||
margin: 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 18mm;
|
||||
height: 18mm;
|
||||
margin-bottom: 1mm;
|
||||
}
|
||||
|
||||
.item-code {
|
||||
font-weight: bold;
|
||||
font-size: 8pt;
|
||||
margin-bottom: 0.5mm;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 34mm;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 6pt;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 34mm;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-weight {
|
||||
font-size: 6pt;
|
||||
color: #666;
|
||||
margin-top: 0.5mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.item-info {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="qr-container">
|
||||
<img id="qr-img" class="qr-img" src="${qrCodeUrl.value}" alt="QR Code" />
|
||||
<div class="item-info">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">${itemCode}</div>
|
||||
<div>${selectedItem.value.produk?.nama || ''}</div>
|
||||
<div style="color: #666; margin-top: 5px;">${selectedItem.value.produk?.berat || ''}g</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
298
resources/js/composables/useNiimbotPrinter.js
Normal file
298
resources/js/composables/useNiimbotPrinter.js
Normal file
@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Menggunakan library@mmote/niimbluelib
|
||||
*/
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
NiimbotBluetoothClient,
|
||||
NiimbotSerialClient,
|
||||
Utils,
|
||||
ImageEncoder,
|
||||
LabelType
|
||||
} from '@mmote/niimbluelib';
|
||||
|
||||
export function useNiimbotPrinter() {
|
||||
// State
|
||||
const printerClient = ref(null);
|
||||
const connectionState = ref('disconnected'); // 'disconnected' | 'connecting' | 'connected'
|
||||
const connectedPrinterName = ref('');
|
||||
const printerInfo = ref(null);
|
||||
const printerMeta = ref(null);
|
||||
const heartbeatData = ref(null);
|
||||
const connectionType = ref('bluetooth'); // 'bluetooth' | 'serial'
|
||||
const printProgress = ref(0);
|
||||
const isPrinting = ref(false);
|
||||
|
||||
// Computed
|
||||
const isConnected = computed(() => connectionState.value === 'connected');
|
||||
const isDisconnected = computed(() => connectionState.value === 'disconnected');
|
||||
const featureSupport = computed(() => Utils.getAvailableTransports());
|
||||
|
||||
/**
|
||||
* Inisialisasi printer client
|
||||
*/
|
||||
const initClient = (type = 'bluetooth') => {
|
||||
connectionType.value = type;
|
||||
|
||||
// Disconnect existing client
|
||||
if (printerClient.value) {
|
||||
printerClient.value.disconnect();
|
||||
}
|
||||
|
||||
// Create new client based on type
|
||||
if (type === 'bluetooth') {
|
||||
printerClient.value = new NiimbotBluetoothClient();
|
||||
} else if (type === 'serial') {
|
||||
printerClient.value = new NiimbotSerialClient();
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
printerClient.value.on('connect', (e) => {
|
||||
console.log('Printer connected:', e);
|
||||
connectionState.value = 'connected';
|
||||
connectedPrinterName.value = e.info.deviceName || 'Niimbot Printer';
|
||||
});
|
||||
|
||||
printerClient.value.on('connect', (e) => {
|
||||
console.log('Printer connected (Serial):', e);
|
||||
console.log('Device details:', e.info);
|
||||
connectionState.value = 'connected';
|
||||
connectedPrinterName.value = e.info.deviceName || 'Niimbot Printer';
|
||||
});
|
||||
|
||||
printerClient.value.on('packetreceived', (e) => {
|
||||
console.log('<< Packet received (Serial):', Utils.bufToHex(e.packet.toBytes()), e);
|
||||
});
|
||||
|
||||
printerClient.value.on('disconnect', () => {
|
||||
console.log('Printer disconnected');
|
||||
connectionState.value = 'disconnected';
|
||||
connectedPrinterName.value = '';
|
||||
printerInfo.value = null;
|
||||
printerMeta.value = null;
|
||||
});
|
||||
|
||||
printerClient.value.on('printerinfofetched', (e) => {
|
||||
console.log('Printer info fetched:', e.info);
|
||||
printerInfo.value = e.info;
|
||||
printerMeta.value = printerClient.value.getModelMetadata();
|
||||
});
|
||||
|
||||
printerClient.value.on('heartbeat', (e) => {
|
||||
heartbeatData.value = e.data;
|
||||
});
|
||||
|
||||
printerClient.value.on('printprogress', (e) => {
|
||||
printProgress.value = Math.floor(
|
||||
(e.page / e.totalPages) * ((e.pagePrintProgress + e.pageFeedProgress) / 2)
|
||||
);
|
||||
});
|
||||
|
||||
// Log packets for debugging
|
||||
printerClient.value.on('packetsent', (e) => {
|
||||
console.log('>> Packet sent:', Utils.bufToHex(e.packet.toBytes()));
|
||||
});
|
||||
|
||||
// printerClient.value.on('packetreceived', (e) => {
|
||||
// console.log('<< Packet received:', Utils.bufToHex(e.packet.toBytes()));
|
||||
// });
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect to printer
|
||||
*/
|
||||
// const connect = async () => {
|
||||
// if (!printerClient.value) {
|
||||
// initClient(connectionType.value);
|
||||
// }
|
||||
|
||||
// connectionState.value = 'connecting';
|
||||
|
||||
// try {
|
||||
// await printerClient.value.connect();
|
||||
// // Connection state will be updated by 'connect' event
|
||||
// } catch (error) {
|
||||
// console.error('Failed to connect to printer:', error);
|
||||
// connectionState.value = 'disconnected';
|
||||
// throw error;
|
||||
// }
|
||||
// };
|
||||
|
||||
const connect = async () => {
|
||||
if (!printerClient.value) {
|
||||
initClient(connectionType.value);
|
||||
}
|
||||
|
||||
connectionState.value = 'connecting';
|
||||
try {
|
||||
const port = await printerClient.value.connect();
|
||||
console.log('Selected serial port:', port);
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to printer (Serial):', error);
|
||||
connectionState.value = 'disconnected';
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect from printer
|
||||
*/
|
||||
const disconnect = () => {
|
||||
if (printerClient.value) {
|
||||
printerClient.value.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Print QR Code image
|
||||
* @param {string} imageDataUrl - Data URL of QR code image
|
||||
* @param {object} options - Print options
|
||||
*/
|
||||
const printQRCode = async (imageDataUrl, options = {}) => {
|
||||
if (!printerClient.value || !isConnected.value) {
|
||||
throw new Error('Printer not connected. Please connect to printer first.');
|
||||
}
|
||||
|
||||
const {
|
||||
density = 3,
|
||||
quantity = 1,
|
||||
labelType = LabelType.WithGaps,
|
||||
printTaskName = 'D110'
|
||||
} = options;
|
||||
|
||||
isPrinting.value = true;
|
||||
printProgress.value = 0;
|
||||
|
||||
try {
|
||||
// Stop heartbeat during print
|
||||
printerClient.value.stopHeartbeat();
|
||||
|
||||
// Create print task
|
||||
const printTask = printerClient.value.abstraction.newPrintTask(printTaskName, {
|
||||
totalPages: quantity,
|
||||
density,
|
||||
labelType,
|
||||
statusPollIntervalMs: 100,
|
||||
statusTimeoutMs: 8000,
|
||||
});
|
||||
|
||||
// Load image into canvas
|
||||
const canvas = await loadImageToCanvas(imageDataUrl);
|
||||
|
||||
// Encode canvas to printer format
|
||||
const encoded = ImageEncoder.encodeCanvas(canvas, 'top'); // 'top' = print direction
|
||||
|
||||
// Initialize print
|
||||
await printTask.printInit();
|
||||
|
||||
// Send print data
|
||||
await printTask.printPage(encoded, quantity);
|
||||
|
||||
// Wait for print to finish
|
||||
await printTask.waitForFinished();
|
||||
|
||||
// End print
|
||||
await printTask.printEnd();
|
||||
|
||||
printProgress.value = 100;
|
||||
|
||||
// Restart heartbeat
|
||||
printerClient.value.startHeartbeat();
|
||||
|
||||
return { success: true, message: 'QR Code printed successfully' };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Print failed:', error);
|
||||
|
||||
// Try to end print gracefully
|
||||
try {
|
||||
await printerClient.value.abstraction.printEnd();
|
||||
printerClient.value.startHeartbeat();
|
||||
} catch (endError) {
|
||||
console.error('Failed to end print:', endError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
isPrinting.value = false;
|
||||
printProgress.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load image data URL into canvas
|
||||
* @param {string} dataUrl - Image data URL
|
||||
* @returns {HTMLCanvasElement}
|
||||
*/
|
||||
const loadImageToCanvas = (dataUrl) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
resolve(canvas);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = dataUrl;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply threshold to canvas for better print quality
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} threshold - 0-255
|
||||
*/
|
||||
const applyThreshold = (canvas, threshold = 140) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
||||
const val = avg < threshold ? 0 : 255;
|
||||
data[i] = val; // red
|
||||
data[i + 1] = val; // green
|
||||
data[i + 2] = val; // blue
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
printerClient,
|
||||
connectionState,
|
||||
connectedPrinterName,
|
||||
printerInfo,
|
||||
printerMeta,
|
||||
heartbeatData,
|
||||
connectionType,
|
||||
printProgress,
|
||||
isPrinting,
|
||||
|
||||
// Computed
|
||||
isConnected,
|
||||
isDisconnected,
|
||||
featureSupport,
|
||||
|
||||
// Methods
|
||||
initClient,
|
||||
connect,
|
||||
disconnect,
|
||||
printQRCode,
|
||||
loadImageToCanvas,
|
||||
applyThreshold,
|
||||
};
|
||||
}
|
||||
@ -54,24 +54,23 @@
|
||||
|
||||
<div class="mb-3 flex flex-row w-full gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1"
|
||||
>Harga per Gram</label
|
||||
>
|
||||
<label class="block text-D mb-1">Harga per Gram</label>
|
||||
<InputField
|
||||
v-model="form.harga_per_gram"
|
||||
type="number"
|
||||
step="0.01"
|
||||
v-model="hargaPerGramFormatted"
|
||||
type="text"
|
||||
placeholder="Masukkan harga per gram"
|
||||
@input="calculateHargaJual"
|
||||
@input="formatHargaPerGramInput"
|
||||
@keypress="onlyNumbers"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Harga Jual</label>
|
||||
<InputField
|
||||
v-model="form.harga_jual"
|
||||
type="number"
|
||||
step="0.01"
|
||||
v-model="hargaJualFormatted"
|
||||
type="text"
|
||||
placeholder="Masukkan harga jual"
|
||||
@input="formatHargaJualInput"
|
||||
@keypress="onlyNumbers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -225,7 +224,6 @@ import mainLayout from "../layouts/mainLayout.vue";
|
||||
import InputField from "../components/InputField.vue";
|
||||
import InputSelect from "../components/InputSelect.vue";
|
||||
import CreateItemModal from "../components/CreateItemModal.vue";
|
||||
import { errorMessages } from "@vue/compiler-core";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -237,8 +235,8 @@ const form = ref({
|
||||
id_kategori: null,
|
||||
berat: 0,
|
||||
kadar: 0,
|
||||
harga_per_gram: 0,
|
||||
harga_jual: 0,
|
||||
harga_per_gram: null,
|
||||
harga_jual: null,
|
||||
});
|
||||
|
||||
const category = ref([]);
|
||||
@ -252,6 +250,62 @@ const fileInput = ref(null);
|
||||
const openItemModal = ref(false);
|
||||
const editedProduct = ref(null);
|
||||
|
||||
// Formatted values for harga_per_gram and harga_jual
|
||||
const hargaPerGramFormatted = ref("");
|
||||
const hargaJualFormatted = ref("");
|
||||
|
||||
// Format angka dengan pemisah ribuan
|
||||
const formatNumber = (num) => {
|
||||
if (!num) return "";
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
};
|
||||
|
||||
// Menghapus format dan mengambil angka asli
|
||||
const unformatNumber = (str) => {
|
||||
if (!str) return null;
|
||||
const cleaned = str.replace(/\./g, "");
|
||||
const number = parseFloat(cleaned);
|
||||
return isNaN(number) ? null : number;
|
||||
};
|
||||
|
||||
// Handler untuk format input harga per gram
|
||||
const formatHargaPerGramInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const cleanValue = value.replace(/\D/g, "");
|
||||
if (cleanValue) {
|
||||
const formatted = formatNumber(cleanValue);
|
||||
hargaPerGramFormatted.value = formatted;
|
||||
form.value.harga_per_gram = parseFloat(cleanValue);
|
||||
calculateHargaJual();
|
||||
} else {
|
||||
hargaPerGramFormatted.value = "";
|
||||
form.value.harga_per_gram = null;
|
||||
calculateHargaJual();
|
||||
}
|
||||
};
|
||||
|
||||
// Handler untuk format input harga jual
|
||||
const formatHargaJualInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const cleanValue = value.replace(/\D/g, "");
|
||||
if (cleanValue) {
|
||||
const formatted = formatNumber(cleanValue);
|
||||
hargaJualFormatted.value = formatted;
|
||||
form.value.harga_jual = parseFloat(cleanValue);
|
||||
} else {
|
||||
hargaJualFormatted.value = "";
|
||||
form.value.harga_jual = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Hanya izinkan angka saat mengetik
|
||||
const onlyNumbers = (event) => {
|
||||
const char = String.fromCharCode(event.which);
|
||||
if (!/[0-9]/.test(char)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
form.value.nama &&
|
||||
@ -267,7 +321,12 @@ const calculateHargaJual = () => {
|
||||
const berat = parseFloat(form.value.berat) || 0;
|
||||
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
|
||||
if (berat > 0 && hargaPerGram > 0) {
|
||||
form.value.harga_jual = berat * hargaPerGram;
|
||||
const hargaJual = berat * hargaPerGram;
|
||||
form.value.harga_jual = hargaJual;
|
||||
hargaJualFormatted.value = formatNumber(hargaJual.toFixed(0));
|
||||
} else {
|
||||
form.value.harga_jual = null;
|
||||
hargaJualFormatted.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
@ -287,8 +346,6 @@ const loadProduk = async () => {
|
||||
},
|
||||
});
|
||||
const produk = response.data;
|
||||
// console.log(produk);
|
||||
|
||||
form.value = {
|
||||
nama: produk.nama,
|
||||
id_kategori: produk.id_kategori,
|
||||
@ -297,6 +354,9 @@ const loadProduk = async () => {
|
||||
harga_per_gram: produk.harga_per_gram,
|
||||
harga_jual: produk.harga_jual,
|
||||
};
|
||||
// Set formatted values after loading
|
||||
hargaPerGramFormatted.value = formatNumber(produk.harga_per_gram?.toString() || "");
|
||||
hargaJualFormatted.value = formatNumber(produk.harga_jual?.toString() || "");
|
||||
};
|
||||
|
||||
const loadFoto = async () => {
|
||||
@ -307,10 +367,8 @@ const loadFoto = async () => {
|
||||
},
|
||||
});
|
||||
uploadedImages.value = response.data;
|
||||
// console.log(uploadedImages.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
uploadError.value = "Gagal memuat foto";
|
||||
}
|
||||
};
|
||||
@ -417,7 +475,7 @@ const submitForm = async () => {
|
||||
);
|
||||
router.push("/produk?message=Produk berhasil diperbarui");
|
||||
} catch (err) {
|
||||
errorMessages.value = err.response?.data?.message || "Gagal menyimpan produk";
|
||||
uploadError.value = err.response?.data?.message || "Gagal menyimpan produk";
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@ -35,13 +35,23 @@
|
||||
<div class="mb-3 flex flex-row w-full gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Harga per Gram</label>
|
||||
<InputField v-model="form.harga_per_gram" type="number" step="0.01"
|
||||
placeholder="Masukkan harga per gram" @input="calculateHargaJual" />
|
||||
<InputField
|
||||
v-model="hargaPerGramFormatted"
|
||||
type="text"
|
||||
placeholder="Masukkan harga per gram"
|
||||
@input="formatHargaPerGramInput"
|
||||
@keypress="onlyNumbers"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Harga Jual</label>
|
||||
<InputField v-model="form.harga_jual" type="number" step="0.01"
|
||||
placeholder="Masukkan harga jual" />
|
||||
<InputField
|
||||
v-model="hargaJualFormatted"
|
||||
type="text"
|
||||
placeholder="Masukkan harga jual"
|
||||
@input="formatHargaJualInput"
|
||||
@keypress="onlyNumbers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -174,7 +184,12 @@ import CreateItemModal from "../components/CreateItemModal.vue";
|
||||
const router = useRouter();
|
||||
|
||||
const form = ref({
|
||||
nama: '', id_kategori: null, berat: null, kadar: null, harga_per_gram: null, harga_jual: null,
|
||||
nama: '',
|
||||
id_kategori: null,
|
||||
berat: null,
|
||||
kadar: null,
|
||||
harga_per_gram: null,
|
||||
harga_jual: null,
|
||||
});
|
||||
const category = ref([]);
|
||||
const showUploadMenu = ref(false);
|
||||
@ -194,17 +209,84 @@ const video = ref(null);
|
||||
const canvas = ref(null);
|
||||
let stream = null;
|
||||
|
||||
// Formatted values for harga_per_gram and harga_jual
|
||||
const hargaPerGramFormatted = ref("");
|
||||
const hargaJualFormatted = ref("");
|
||||
|
||||
// Format angka dengan pemisah ribuan
|
||||
const formatNumber = (num) => {
|
||||
if (!num) return "";
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
};
|
||||
|
||||
// Menghapus format dan mengambil angka asli
|
||||
const unformatNumber = (str) => {
|
||||
if (!str) return null;
|
||||
const cleaned = str.replace(/\./g, "");
|
||||
const number = parseFloat(cleaned);
|
||||
return isNaN(number) ? null : number;
|
||||
};
|
||||
|
||||
// Handler untuk format input harga per gram
|
||||
const formatHargaPerGramInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const cleanValue = value.replace(/\D/g, "");
|
||||
if (cleanValue) {
|
||||
const formatted = formatNumber(cleanValue);
|
||||
hargaPerGramFormatted.value = formatted;
|
||||
form.value.harga_per_gram = parseFloat(cleanValue);
|
||||
calculateHargaJual();
|
||||
} else {
|
||||
hargaPerGramFormatted.value = "";
|
||||
form.value.harga_per_gram = null;
|
||||
calculateHargaJual();
|
||||
}
|
||||
};
|
||||
|
||||
// Handler untuk format input harga jual
|
||||
const formatHargaJualInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const cleanValue = value.replace(/\D/g, "");
|
||||
if (cleanValue) {
|
||||
const formatted = formatNumber(cleanValue);
|
||||
hargaJualFormatted.value = formatted;
|
||||
form.value.harga_jual = parseFloat(cleanValue);
|
||||
} else {
|
||||
hargaJualFormatted.value = "";
|
||||
form.value.harga_jual = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Hanya izinkan angka saat mengetik
|
||||
const onlyNumbers = (event) => {
|
||||
const char = String.fromCharCode(event.which);
|
||||
if (!/[0-9]/.test(char)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.value.nama && form.value.id_kategori && form.value.berat > 0 &&
|
||||
form.value.kadar > 0 && form.value.harga_per_gram > 0 && form.value.harga_jual > 0 &&
|
||||
uploadedImages.value.length > 0;
|
||||
return (
|
||||
form.value.nama &&
|
||||
form.value.id_kategori &&
|
||||
form.value.berat > 0 &&
|
||||
form.value.kadar > 0 &&
|
||||
form.value.harga_per_gram > 0 &&
|
||||
form.value.harga_jual > 0 &&
|
||||
uploadedImages.value.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const calculateHargaJual = () => {
|
||||
const berat = parseFloat(form.value.berat) || 0;
|
||||
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
|
||||
if (berat > 0 && hargaPerGram > 0) {
|
||||
form.value.harga_jual = berat * hargaPerGram;
|
||||
const hargaJual = berat * hargaPerGram;
|
||||
form.value.harga_jual = hargaJual;
|
||||
hargaJualFormatted.value = formatNumber(hargaJual.toFixed(0));
|
||||
} else {
|
||||
form.value.harga_jual = null;
|
||||
hargaJualFormatted.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
@ -214,7 +296,7 @@ const loadKategori = async () => {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
category.value = response.data.map(cat => ({ value: cat.id, label: cat.nama }));
|
||||
category.value = response.data.map((cat) => ({ value: cat.id, label: cat.nama }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
@ -263,11 +345,17 @@ const uploadFiles = async (files) => {
|
||||
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
|
||||
return;
|
||||
}
|
||||
const validFiles = files.filter(file => {
|
||||
const validFiles = files.filter((file) => {
|
||||
const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
|
||||
const isValidSize = file.size <= 2 * 1024 * 1024;
|
||||
if (!isValidType) { uploadError.value = 'Format file harus JPG, JPEG, atau PNG'; return false; }
|
||||
if (!isValidSize) { uploadError.value = 'Ukuran file maksimal 2MB'; return false; }
|
||||
if (!isValidType) {
|
||||
uploadError.value = 'Format file harus JPG, JPEG, atau PNG';
|
||||
return false;
|
||||
}
|
||||
if (!isValidSize) {
|
||||
uploadError.value = 'Ukuran file maksimal 2MB';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (validFiles.length === 0) return;
|
||||
@ -277,7 +365,10 @@ const uploadFiles = async (files) => {
|
||||
const formData = new FormData();
|
||||
formData.append('foto', file);
|
||||
const response = await axios.post('/api/foto', formData, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}`, 'Content-Type': 'multipart/form-data' },
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
uploadedImages.value.push(response.data);
|
||||
}
|
||||
@ -318,7 +409,7 @@ const openCameraModal = async () => {
|
||||
const closeCamera = () => {
|
||||
showCamera.value = false;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
};
|
||||
@ -346,7 +437,16 @@ const submitForm = async (addItem) => {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
});
|
||||
const createdProductData = response.data.data;
|
||||
form.value = { nama: '', id_kategori: '', berat: 0, kadar: 0, harga_per_gram: 0, harga_jual: 0 };
|
||||
form.value = {
|
||||
nama: '',
|
||||
id_kategori: '',
|
||||
berat: 0,
|
||||
kadar: 0,
|
||||
harga_per_gram: 0,
|
||||
harga_jual: 0,
|
||||
};
|
||||
hargaPerGramFormatted.value = "";
|
||||
hargaJualFormatted.value = "";
|
||||
uploadedImages.value = [];
|
||||
uploadError.value = '';
|
||||
showUploadMenu.value = false;
|
||||
@ -368,9 +468,24 @@ const submitForm = async (addItem) => {
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => { router.push('/produk'); };
|
||||
const openCreateItemModal = (product) => { createdProduct.value = product; openItemModal.value = true; };
|
||||
const closeItemModal = () => { openItemModal.value = false; createdProduct.value = null; };
|
||||
const back = () => {
|
||||
router.push('/produk');
|
||||
};
|
||||
|
||||
onMounted(() => { loadFoto(); loadKategori(); });
|
||||
const openCreateItemModal = (product) => {
|
||||
createdProduct.value = product;
|
||||
openItemModal.value = true;
|
||||
};
|
||||
|
||||
const closeItemModal = () => {
|
||||
openItemModal.value = false;
|
||||
createdProduct.value = null;
|
||||
router.push('/produk');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadFoto();
|
||||
loadKategori();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user