[Update] print label

Library niimblue tidak digunakan, namun potongan kode tetap disimpan
This commit is contained in:
Baghaztra 2025-10-15 14:33:47 +07:00
parent c8559d63df
commit 8665584567
11 changed files with 2060 additions and 169 deletions

View 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! 🎉

View 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.

Binary file not shown.

24
package-lock.json generated
View File

@ -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"

View File

@ -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,9 +48,8 @@
@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"
@error="handleImageError" />
<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>
</div>
@ -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,41 +112,37 @@
</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">
Batal
</button>
<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"
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>
<button @click="saveMove" :disabled="!selectedTrayId || isMoving"
class="px-4 py-2 rounded text-D transition flex items-center"
:class="(selectedTrayId && !isMoving) ? 'bg-C hover:bg-C/80' : 'bg-gray-400 cursor-not-allowed'">
<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>
<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>
<button @click="saveMove" :disabled="!selectedTrayId || isMoving"
class="px-4 py-2 rounded text-D transition flex items-center"
:class="(selectedTrayId && !isMoving) ? 'bg-C hover:bg-C/80' : 'bg-gray-400 cursor-not-allowed'">
<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>
<!-- 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-info {
margin-top: 10px;
font-size: 14px;
.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;
}
}
</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;
}

View File

@ -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-info {
margin-top: 10px;
font-size: 14px;
.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;
}
}
</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>

View 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>

View File

@ -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-info {
margin-top: 10px;
font-size: 14px;
.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;
}
}
</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>

View 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,
};
}

View File

@ -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;

View File

@ -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>