Compare commits
37 Commits
main
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bbbba4a11 | ||
|
|
d55399ed55 | ||
|
|
a37102c3ae | ||
|
|
9e7ef1b825 | ||
|
|
3809b295a5 | ||
|
|
48ffd8ac57 | ||
|
|
178c09c3c4 | ||
|
|
b186e7ca23 | ||
|
|
f957cf4e5a | ||
|
|
98a8096725 | ||
|
|
65e1bdd116 | ||
|
|
f2b1ba34a7 | ||
|
|
e82c4c6d91 | ||
|
|
7cd3e54402 | ||
|
|
d48ec1a1fd | ||
|
|
4d1bebc6a6 | ||
|
|
945cea3b4c | ||
|
|
b44eb2cdc3 | ||
|
|
42de65fc6b | ||
|
|
0dc0cb365f | ||
|
|
06ec582ffb | ||
|
|
d442e2e691 | ||
|
|
878e652630 | ||
|
|
bab572e2ca | ||
|
|
6a42f15822 | ||
|
|
508d636675 | ||
|
|
a0100af842 | ||
|
|
271a1e3660 | ||
|
|
065c21e07c | ||
|
|
ecc9385c28 | ||
|
|
e1639109c8 | ||
|
|
e54a021b98 | ||
|
|
8665584567 | ||
|
|
c8559d63df | ||
|
|
e226faf08a | ||
|
|
43e058fe6f | ||
|
|
899a81c709 |
79
.env.docker
79
.env.docker
@ -1,86 +1,65 @@
|
|||||||
# ========================================
|
|
||||||
# Abbauf Kasir - Docker Environment Configuration
|
|
||||||
# ========================================
|
|
||||||
# Copy file ini ke .env sebelum deployment
|
|
||||||
# Ganti semua placeholder dengan nilai yang sesuai
|
|
||||||
|
|
||||||
# Application
|
|
||||||
APP_NAME=Abbauf-Kasir
|
APP_NAME=Abbauf-Kasir
|
||||||
APP_ENV=production
|
APP_ENV=local
|
||||||
APP_KEY= # Generate dengan: php artisan key:generate
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost # Ganti dengan domain production
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=id
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=id_ID
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
PHP_CLI_SERVER_WORKERS=4
|
PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=daily
|
LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=error
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
# Database Configuration
|
DB_CONNECTION=sqlite
|
||||||
DB_CONNECTION=mysql
|
# DB_HOST=127.0.0.1
|
||||||
DB_HOST=mysql # Nama service di docker-compose
|
# DB_PORT=3306
|
||||||
DB_PORT=3306
|
# DB_DATABASE=laravel
|
||||||
DB_DATABASE=kasir_db # Nama database
|
# DB_USERNAME=root
|
||||||
DB_USERNAME=kasir_user # Username database
|
# DB_PASSWORD=
|
||||||
DB_PASSWORD=strong_password_here # GANTI dengan password kuat!
|
|
||||||
|
|
||||||
# MySQL Root Password (untuk docker-compose)
|
SESSION_DRIVER=database
|
||||||
MYSQL_ROOT_PASSWORD=root_strong_password # GANTI dengan password root yang kuat!
|
|
||||||
|
|
||||||
# Cache & Session
|
|
||||||
CACHE_STORE=redis
|
|
||||||
SESSION_DRIVER=redis
|
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
# Redis Configuration
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
REDIS_CLIENT=phpredis
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# Queue
|
|
||||||
QUEUE_CONNECTION=redis
|
|
||||||
|
|
||||||
# Broadcasting
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
|
|
||||||
# Filesystem
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
|
|
||||||
# Mail Configuration (opsional)
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=127.0.0.1
|
MAIL_HOST=127.0.0.1
|
||||||
MAIL_PORT=2525
|
MAIL_PORT=2525
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="noreply@abbauf-kasir.local"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
# AWS (jika menggunakan S3 untuk storage)
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
# Ports (untuk docker-compose)
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
APP_PORT=80 # Port untuk akses aplikasi
|
|
||||||
# DB_PORT=3306 # Uncomment jika ingin expose DB port
|
|
||||||
# REDIS_PORT=6379 # Uncomment jika ingin expose Redis port
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
APP_NAME=Abbauf-Kasir
|
APP_NAME=Abbauf-Kasir
|
||||||
APP_ENV=production
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
|
|||||||
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.
|
||||||
192
README.md
192
README.md
@ -66,50 +66,7 @@ Semuah sistem Point of Sale (POS) yang dirancang khusus untuk kebutuhan toko per
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## <20> Docker Installation (Recommended)
|
## Installation
|
||||||
|
|
||||||
### Quick Start dengan Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Clone repository
|
|
||||||
git clone https://git.abbauf.com/Magang-2025/Kasir.git
|
|
||||||
cd Kasir
|
|
||||||
|
|
||||||
# 2. Setup environment
|
|
||||||
copy .env.docker .env # Windows
|
|
||||||
# atau
|
|
||||||
cp .env.docker .env # Linux/Mac
|
|
||||||
|
|
||||||
# 3. Edit .env (PENTING: ganti password!)
|
|
||||||
notepad .env # Windows
|
|
||||||
nano .env # Linux/Mac
|
|
||||||
|
|
||||||
# 4. Deploy dengan satu command
|
|
||||||
docker-deploy.bat # Windows
|
|
||||||
# atau
|
|
||||||
./docker-deploy.sh # Linux/Mac
|
|
||||||
|
|
||||||
# 5. Akses aplikasi
|
|
||||||
# http://localhost
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dokumentasi Docker Lengkap:**
|
|
||||||
- 📖 Quick Start: [README-DOCKER.md](README-DOCKER.md)
|
|
||||||
- 📚 Full Guide: [DOCKER-DEPLOYMENT.md](DOCKER-DEPLOYMENT.md)
|
|
||||||
- 📋 Checklist: [DEPLOYMENT-CHECKLIST.md](DEPLOYMENT-CHECKLIST.md)
|
|
||||||
- 🎯 Quick Reference: [QUICK-REFERENCE.txt](QUICK-REFERENCE.txt)
|
|
||||||
|
|
||||||
**Management Commands:**
|
|
||||||
```bash
|
|
||||||
docker-helper.bat status # Cek status containers
|
|
||||||
docker-helper.bat logs # Lihat logs
|
|
||||||
docker-helper.bat backup # Backup database
|
|
||||||
docker-helper.bat restart # Restart containers
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## <20>🔧 Manual Installation
|
|
||||||
|
|
||||||
### 1. Clone Repository
|
### 1. Clone Repository
|
||||||
|
|
||||||
@ -151,8 +108,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Setup Database
|
### 5. Setup Database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -177,6 +132,44 @@ php artisan storage:link
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Production Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pastikan env production sudah tersedia
|
||||||
|
docker compose --env-file .env.production up -d --build
|
||||||
|
|
||||||
|
# Siapkan database
|
||||||
|
docker exec -it abbauf_kasir_app php artisan migrate --seed
|
||||||
|
|
||||||
|
# Atau import database secara manual
|
||||||
|
docker exec -i abbauf_kasir_db mysql -u kasir_user -pkasir_password kasir_db < ./storage/toko_emas.sql
|
||||||
|
|
||||||
|
# Periksa database (opsional)
|
||||||
|
docker exec -it abbauf_kasir_db bash
|
||||||
|
mysql -u kasir_user -pkasir_password kasir_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Print Label
|
||||||
|
|
||||||
|
- Install driver, ada pada folder `./driver/NiimbotPrinterDriverInstall_3.0.0.5.exe` untuk windows 64bit.
|
||||||
|
- Pilih `NIIMBOT B3S_P` pada saat install driver.
|
||||||
|
- Sambungkan printer ke komputer via USB.
|
||||||
|
- Nyalakan printer.
|
||||||
|
- Temukan kode qr yang akan diprint (misalnya pada halaman brankas)
|
||||||
|
- Klik tombol print pada halaman tersebut
|
||||||
|
- Pilih printer `NIIMBOT B3S_P` dan atur kertas ke ukuran kertas `40mm x 30mm`, margin `Default`, scale `Default`
|
||||||
|
- Klik print
|
||||||
|
|
||||||
|
### 9. Print Nota
|
||||||
|
|
||||||
|
- Pastikan printer terhubung dengan komputer via USB.
|
||||||
|
- Nyalakan printer.
|
||||||
|
- Install driver, ada pada folder `./driver/L120_x64_213UsHomeExportAsiaML.exe`.
|
||||||
|
- Lakukan transaksi penjualan pada aplikasi, atau pilih nota yang akan diprint di `Laporan > Riwayat transaksi`.
|
||||||
|
- Klik tombol print pada halaman tersebut
|
||||||
|
- Pilih ukuran kertas A4, margin `Minimum`, scale `95`
|
||||||
|
- Klik print
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 Akses Aplikasi
|
## 🌐 Akses Aplikasi
|
||||||
@ -184,19 +177,18 @@ npm run dev
|
|||||||
Setelah instalasi berhasil, akses aplikasi melalui:
|
Setelah instalasi berhasil, akses aplikasi melalui:
|
||||||
|
|
||||||
- **URL**: http://localhost:8000
|
- **URL**: http://localhost:8000
|
||||||
- **Admin Panel**: http://localhost:8000/admin (jika tersedia)
|
|
||||||
|
|
||||||
### 👤 Default Login
|
### 👤 Default Login
|
||||||
|
|
||||||
**Owner Account:**
|
**Owner Account:**
|
||||||
|
|
||||||
- Email: `owner@tokoperhiasan.com`
|
- Username: `admin`
|
||||||
- Password: `password123`
|
- Password: `123123`
|
||||||
|
|
||||||
**Kasir Account:**
|
**Kasir Account:**
|
||||||
|
|
||||||
- Email: `kasir@tokoperhiasan.com`
|
- Username: `kasir`
|
||||||
- Password: `password123`
|
- Password: `123123`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -229,10 +221,6 @@ kasir-toko-perhiasan/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Oke, jadi penjelasan “📊 Fitur Database” yang kamu tulis nggak sepenuhnya sesuai sama struktur tabel yang udah kamu definisikan di awal. Ada tabel yang salah nama, ada juga relasi yang kebalik. Gue rapihin biar konsisten dengan skema yang udah kamu kasih:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Fitur Database (Revisi)
|
## 📊 Fitur Database (Revisi)
|
||||||
|
|
||||||
### Tabel Utama
|
### Tabel Utama
|
||||||
@ -275,100 +263,6 @@ ItemTransaksi -> belongsTo -> Item
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Reset database dan re-seed
|
|
||||||
php artisan migrate:fresh --seed
|
|
||||||
|
|
||||||
# Backup database
|
|
||||||
php artisan backup:run
|
|
||||||
|
|
||||||
# Generate model dengan migration
|
|
||||||
php artisan make:model ProductCategory -m
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Deployment
|
|
||||||
|
|
||||||
**Dengan Docker (Recommended):**
|
|
||||||
```bash
|
|
||||||
# 1. Setup .env untuk production
|
|
||||||
copy .env.docker .env
|
|
||||||
# Edit: APP_ENV=production, APP_DEBUG=false, set password kuat
|
|
||||||
|
|
||||||
# 2. Deploy
|
|
||||||
docker-deploy.bat
|
|
||||||
|
|
||||||
# 3. Monitor
|
|
||||||
docker-helper.bat status
|
|
||||||
docker-helper.bat logs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual Deployment:**
|
|
||||||
```bash
|
|
||||||
# Build assets
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Optimize Laravel
|
|
||||||
php artisan config:cache
|
|
||||||
php artisan route:cache
|
|
||||||
php artisan view:cache
|
|
||||||
|
|
||||||
# Set permissions
|
|
||||||
chmod -R 775 storage bootstrap/cache
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dokumentasi lengkap:** [DOCKER-DEPLOYMENT.md](DOCKER-DEPLOYMENT.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Update Aplikasi
|
|
||||||
|
|
||||||
**Dengan Docker:**
|
|
||||||
```bash
|
|
||||||
docker-helper.bat update # Otomatis: pull, rebuild, migrate, cache
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual:**
|
|
||||||
```bash
|
|
||||||
git pull origin main
|
|
||||||
composer install --no-dev
|
|
||||||
npm install && npm run build
|
|
||||||
php artisan migrate --force
|
|
||||||
php artisan config:cache
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💾 Backup & Restore
|
|
||||||
|
|
||||||
**Backup Database (Docker):**
|
|
||||||
```bash
|
|
||||||
docker-helper.bat backup
|
|
||||||
# File tersimpan di: docker/mysql/backups/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Restore Database (Docker):**
|
|
||||||
```bash
|
|
||||||
docker-helper.bat restore
|
|
||||||
# Pilih file backup yang tersedia
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual Backup:**
|
|
||||||
```bash
|
|
||||||
mysqldump -u root -p toko_emas > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
Lisensi dan kepemilikan atascource code adalah milik PT Teknologi Mulia Sejahtera Cemerlang.
|
Lisensi dan kepemilikan atascource code adalah milik PT Teknologi Mulia Sejahtera Cemerlang.
|
||||||
|
|||||||
@ -75,6 +75,7 @@ class TransaksiController extends Controller
|
|||||||
'kasir',
|
'kasir',
|
||||||
'sales',
|
'sales',
|
||||||
'itemTransaksi.produk',
|
'itemTransaksi.produk',
|
||||||
|
'itemTransaksi.produk.foto',
|
||||||
'itemTransaksi' => function ($query) {
|
'itemTransaksi' => function ($query) {
|
||||||
$query->orderBy('created_at', 'asc');
|
$query->orderBy('created_at', 'asc');
|
||||||
}
|
}
|
||||||
@ -103,7 +104,7 @@ class TransaksiController extends Controller
|
|||||||
'ongkos_bikin' => 'nullable|numeric|min:0',
|
'ongkos_bikin' => 'nullable|numeric|min:0',
|
||||||
'total_harga' => 'required|numeric',
|
'total_harga' => 'required|numeric',
|
||||||
'items' => 'required|array',
|
'items' => 'required|array',
|
||||||
'items.*.kode_item' => 'required|exists:items,id|numeric',
|
'items.*.kode_item' => 'required',
|
||||||
'items.*.harga_deal' => 'required|numeric',
|
'items.*.harga_deal' => 'required|numeric',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -23,24 +23,15 @@ class Item extends Model
|
|||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
static::creating(function ($item) {
|
static::created(function ($item) {
|
||||||
$prefix = 'TMJC';
|
if (!$item->kode_item || $item->kode_item === 'belum pak') {
|
||||||
$date = now()->format('Ymd');
|
$prefix = "TMJC";
|
||||||
|
$date = $item->created_at->format('Ymd');
|
||||||
|
$number = str_pad($item->id, 4, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
// Cari item terakhir yg dibuat hari ini
|
$item->kode_item = $prefix . $date . $number;
|
||||||
$lastItem = self::whereDate('created_at', now()->toDateString())
|
$item->save();
|
||||||
->orderBy('id', 'desc')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$number = 1;
|
|
||||||
if ($lastItem && $lastItem->kode_item) {
|
|
||||||
// Ambil 4 digit terakhir dari kode_item
|
|
||||||
$lastNumber = intval(substr($lastItem->kode_item, -4));
|
|
||||||
$number = $lastNumber + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: ITM202509090001
|
|
||||||
$item->kode_item = $prefix . $date . str_pad($number, 4, '0', STR_PAD_LEFT);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,10 +12,10 @@ return new class extends Migration
|
|||||||
public function up()
|
public function up()
|
||||||
{
|
{
|
||||||
Schema::table('items', function (Blueprint $table) {
|
Schema::table('items', function (Blueprint $table) {
|
||||||
$table->string('kode_item')->unique()->after('id');
|
$table->string('kode_item')->unique()->default('belum pak')->after('id');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down()
|
public function down()
|
||||||
{
|
{
|
||||||
Schema::table('items', function (Blueprint $table) {
|
Schema::table('items', function (Blueprint $table) {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ namespace Database\Seeders;
|
|||||||
use App\Models\Kategori;
|
use App\Models\Kategori;
|
||||||
use App\Models\Nampan;
|
use App\Models\Nampan;
|
||||||
use App\Models\Produk;
|
use App\Models\Produk;
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class DataSeeder extends Seeder
|
class DataSeeder extends Seeder
|
||||||
@ -16,47 +15,60 @@ class DataSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// Nampan
|
// Nampan
|
||||||
for ($i=0; $i < 30; $i++) {
|
for ($i = 0; $i < 30; $i++) {
|
||||||
if ($i != 12) {
|
if ($i != 12) {
|
||||||
Nampan::factory()->create([
|
Nampan::create([
|
||||||
'nama' => 'A' . ($i + 1)
|
'nama' => 'A' . ($i + 1),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kategori
|
// Kategori
|
||||||
$kategoriList = ['Cincin', 'Gelang Rantai', 'Gelang Bulat', 'Kalung', 'Liontin', 'Anting', 'Giwang'];
|
$kategoriList = ['Cincin', 'Gelang Rantai', 'Gelang Bulat', 'Kalung', 'Liontin', 'Anting', 'Giwang'];
|
||||||
foreach ($kategoriList as $kategori) {
|
foreach ($kategoriList as $index => $kategori) {
|
||||||
Kategori::factory()->create([
|
Kategori::create([
|
||||||
'nama' => $kategori
|
'nama' => $kategori,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produk
|
// Produk 1
|
||||||
$produk1 = Produk::factory()->create([
|
$produk1 = Produk::create([
|
||||||
'nama'=>'Gelang serut daun shimmer mp (mas putih)',
|
'nama' => 'Gelang serut daun shimmer mp (mas putih)',
|
||||||
'id_kategori'=>Kategori::find(2),
|
'id_kategori' => Kategori::where('nama', 'Gelang Rantai')->first()->id,
|
||||||
'berat'=>1.4,
|
'berat' => 1.4,
|
||||||
'kadar'=>8,
|
'kadar' => 8,
|
||||||
'harga_per_gram'=>900000,
|
'harga_per_gram' => 900000,
|
||||||
'harga_jual'=>1260000,
|
'harga_jual' => 1260000,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
$produk1->foto()->create([
|
$produk1->foto()->create([
|
||||||
'id_produk'=>$produk1->id,
|
'id_produk' => $produk1->id,
|
||||||
'url'=>'https://i.imgur.com/eGYHzvw.jpeg'
|
'url' => 'https://i.imgur.com/eGYHzvw.jpeg',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$produk2 = Produk::factory()->create([
|
// Produk 2
|
||||||
'nama'=>'Gelang rantai 5 buah clover merah',
|
$produk2 = Produk::create([
|
||||||
'id_kategori'=>Kategori::find(2),
|
'nama' => 'Gelang rantai 5 buah clover merah',
|
||||||
'berat'=>3.6,
|
'id_kategori' => Kategori::where('nama', 'Gelang Rantai')->first()->id,
|
||||||
'kadar'=>8,
|
'berat' => 3.6,
|
||||||
'harga_per_gram'=>850000,
|
'kadar' => 8,
|
||||||
'harga_jual'=>3060000,
|
'harga_per_gram' => 850000,
|
||||||
|
'harga_jual' => 3060000,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
$produk2->foto()->create([
|
$produk2->foto()->create([
|
||||||
'id_produk'=>$produk2->id,
|
'id_produk' => $produk2->id,
|
||||||
'url'=>'https://i.imgur.com/UjQzYoE.jpeg'
|
'url' => 'https://i.imgur.com/UjQzYoE.jpeg',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,8 +9,8 @@ use App\Models\Produk;
|
|||||||
use App\Models\Sales;
|
use App\Models\Sales;
|
||||||
use App\Models\Transaksi;
|
use App\Models\Transaksi;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@ -19,24 +19,27 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
User::factory()->create([
|
// Create admin user
|
||||||
|
User::create([
|
||||||
'nama' => 'admin',
|
'nama' => 'admin',
|
||||||
'role' => 'owner',
|
'role' => 'owner',
|
||||||
'password' => bcrypt('123123'),
|
'password' => Hash::make('123123'),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
User::factory()->create([
|
|
||||||
|
// Create kasir user
|
||||||
|
User::create([
|
||||||
'nama' => 'kasir',
|
'nama' => 'kasir',
|
||||||
'role' => 'kasir',
|
'role' => 'kasir',
|
||||||
'password' => bcrypt('123123'),
|
'password' => Hash::make('123123'),
|
||||||
]);
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
Sales::factory()->create([
|
|
||||||
'nama' => 'Umum',
|
|
||||||
'no_hp' => '-',
|
|
||||||
'alamat' => '-',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
// Call other seeders
|
||||||
$this->call(DataSeeder::class);
|
$this->call(DataSeeder::class);
|
||||||
// $this->call(DummySeeder::class);
|
$this->call(DummySeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
version: '3.8'
|
version: '3.9'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ========================================
|
# ========================================
|
||||||
@ -13,7 +13,7 @@ services:
|
|||||||
working_dir: /var/www/html
|
working_dir: /var/www/html
|
||||||
volumes:
|
volumes:
|
||||||
# Mount storage untuk uploads dan logs (persistent)
|
# Mount storage untuk uploads dan logs (persistent)
|
||||||
- ./storage:/var/www/html/storage
|
- ./storage/app/public:/var/www/html/storage/app/public
|
||||||
# Mount public build assets (read-only)
|
# Mount public build assets (read-only)
|
||||||
- ./public/build:/var/www/html/public/build:ro
|
- ./public/build:/var/www/html/public/build:ro
|
||||||
environment:
|
environment:
|
||||||
@ -47,11 +47,11 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- kasir_network
|
- kasir_network
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD", "php-fpm", "-t"]
|
# test: ["CMD", "php-fpm", "-t"]
|
||||||
interval: 30s
|
# interval: 30s
|
||||||
timeout: 10s
|
# timeout: 10s
|
||||||
retries: 3
|
# retries: 3
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Nginx Web Server
|
# Nginx Web Server
|
||||||
@ -61,29 +61,30 @@ services:
|
|||||||
container_name: abbauf_kasir_nginx
|
container_name: abbauf_kasir_nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-80}:80"
|
- "81:80"
|
||||||
volumes:
|
volumes:
|
||||||
# Nginx configuration
|
# Nginx configuration
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
# Laravel public directory (untuk static assets)
|
# Laravel public directory (untuk static assets)
|
||||||
- ./public:/var/www/html/public:ro
|
- ./public:/var/www/html/public
|
||||||
# Storage symlink untuk file uploads
|
# Storage symlink untuk file uploads
|
||||||
- ./storage/app/public:/var/www/html/public/storage:ro
|
- ./storage/app/public:/var/www/html/public/storage
|
||||||
depends_on:
|
depends_on:
|
||||||
- laravel
|
- laravel
|
||||||
networks:
|
networks:
|
||||||
- kasir_network
|
- kasir_network
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
# test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||||
interval: 30s
|
# interval: 30s
|
||||||
timeout: 10s
|
# timeout: 10s
|
||||||
retries: 3
|
# retries: 3
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# MySQL Database
|
# MySQL Database
|
||||||
# ========================================
|
# ========================================
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.4
|
# image: mysql:8.4
|
||||||
|
image: mariadb:10.6
|
||||||
container_name: abbauf_kasir_db
|
container_name: abbauf_kasir_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@ -94,7 +95,7 @@ services:
|
|||||||
MYSQL_CHARACTER_SET_SERVER: utf8mb4
|
MYSQL_CHARACTER_SET_SERVER: utf8mb4
|
||||||
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
|
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-3306}:3306"
|
- "3308:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
# Optional: backup folder
|
# Optional: backup folder
|
||||||
@ -106,7 +107,7 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
command: --default-authentication-plugin=mysql_native_password
|
# command: --default-authentication-plugin=mysql_native_password
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Redis Cache & Session Store
|
# Redis Cache & Session Store
|
||||||
@ -116,16 +117,16 @@ services:
|
|||||||
container_name: abbauf_kasir_redis
|
container_name: abbauf_kasir_redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "6380:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
networks:
|
networks:
|
||||||
- kasir_network
|
- kasir_network
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
# test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 10s
|
# interval: 10s
|
||||||
timeout: 5s
|
# timeout: 5s
|
||||||
retries: 3
|
# retries: 3
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
BIN
driver/L120_x64_213UsHomeExportAsiaML.exe
Normal file
BIN
driver/L120_x64_213UsHomeExportAsiaML.exe
Normal file
Binary file not shown.
BIN
driver/NiimbotPrinterDriverInstall_3.0.0.5.exe
Normal file
BIN
driver/NiimbotPrinterDriverInstall_3.0.0.5.exe
Normal file
Binary file not shown.
@ -85,4 +85,4 @@ server {
|
|||||||
return 200 "healthy\n";
|
return 200 "healthy\n";
|
||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@ -1874,9 +1874,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.11.0",
|
"version": "1.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -3700,14 +3700,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2"
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@ -3805,9 +3805,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
|
||||||
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -3816,7 +3816,7 @@
|
|||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.43.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.14"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
|
|||||||
@ -3,15 +3,17 @@
|
|||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
|
||||||
<span class="ml-2 text-gray-600">Memuat data...</span>
|
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Alert Section -->
|
<!-- Alert Section -->
|
||||||
<div class="mb-4" v-if="alert">
|
<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>
|
<strong class="font-bold">Error!</strong>
|
||||||
<span class="block sm:inline">{{ alert.error }}</span>
|
<span class="block sm:inline">{{ alert.error }}</span>
|
||||||
</div>
|
</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>
|
<strong class="font-bold">Success!</strong>
|
||||||
<span class="block sm:inline">{{ alert.success }}</span>
|
<span class="block sm:inline">{{ alert.success }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -46,9 +48,8 @@
|
|||||||
@click="openMovePopup(item)">
|
@click="openMovePopup(item)">
|
||||||
<!-- Gambar & Info Produk -->
|
<!-- Gambar & Info Produk -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<img v-if="item.produk?.foto?.length" :src="item.produk?.foto[0].url"
|
<img v-if="item.produk?.foto?.length" :src="item.produk?.foto[0].url" class="size-12 object-cover rounded"
|
||||||
class="size-12 object-cover rounded"
|
@error="handleImageError" />
|
||||||
@error="handleImageError" />
|
|
||||||
<div class="size-12 bg-gray-200 rounded flex items-center justify-center" v-else>
|
<div class="size-12 bg-gray-200 rounded flex items-center justify-center" v-else>
|
||||||
<i class="fas fa-image text-gray-400"></i>
|
<i class="fas fa-image text-gray-400"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -64,32 +65,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Pindah Nampan -->
|
<!-- 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 v-if="isPopupVisible"
|
||||||
<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">
|
class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||||
<!-- QR Code -->
|
<div
|
||||||
<div class="flex justify-center mb-4">
|
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 class="p-2 border border-C rounded-lg">
|
<PrintBarcode :code="selectedItem?.kode_item" :item="selectedItem?.produk" />
|
||||||
<img :src="qrCodeUrl" alt="QR Code" class="w-36 h-36" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info Produk -->
|
|
||||||
<div class="text-center text-D font-bold text-lg mb-1">
|
|
||||||
{{ selectedItem?.kode_item }}
|
|
||||||
</div>
|
|
||||||
<div class="text-center text-gray-700 font-medium mb-1">
|
|
||||||
{{ selectedItem?.produk?.nama }}
|
|
||||||
</div>
|
|
||||||
<div class="text-center text-gray-500 text-sm mb-4">
|
|
||||||
{{ selectedItem?.produk?.kategori }}
|
|
||||||
</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
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dropdown pilih nampan -->
|
<!-- Dropdown pilih nampan -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@ -107,41 +87,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tombol -->
|
<!-- Tombol -->
|
||||||
<!-- Tombol -->
|
<div class="flex justify-end gap-2">
|
||||||
<div class="flex justify-end gap-2">
|
<button @click="closePopup"
|
||||||
<button @click="closePopup" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
||||||
Batal
|
Batal
|
||||||
</button>
|
</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">
|
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
|
<i class="fas fa-trash mr-2"></i>Hapus
|
||||||
</button>
|
</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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Konfirmasi Hapus -->
|
<!-- Modal Konfirmasi Hapus -->
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal :isOpen="showDeleteConfirm" title="Konfirmasi Hapus Item"
|
||||||
:isOpen="showDeleteConfirm"
|
message="Apakah kamu yakin ingin menghapus item ini?" confirmText="Ya, Hapus" cancelText="Batal"
|
||||||
title="Konfirmasi Hapus Item"
|
@confirm="confirmDelete" @cancel="cancelDelete" />
|
||||||
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) -->
|
<!-- 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 v-if="isConfirmModalVisible"
|
||||||
<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">
|
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 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">
|
<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>
|
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||||
@ -154,10 +130,12 @@
|
|||||||
<p class="text-sm text-gray-500" v-html="confirmModalMessage"></p>
|
<p class="text-sm text-gray-500" v-html="confirmModalMessage"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
<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 }}
|
{{ cancelText }}
|
||||||
</button>
|
</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 }}
|
{{ confirmText }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -170,6 +148,7 @@
|
|||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
|
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
|
||||||
|
import PrintBarcode from './PrintBarcode.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
search: {
|
search: {
|
||||||
@ -181,7 +160,6 @@ const props = defineProps({
|
|||||||
const items = ref([]);
|
const items = ref([]);
|
||||||
const trays = ref([]);
|
const trays = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(null);
|
|
||||||
const alert = ref(null);
|
const alert = ref(null);
|
||||||
const timer = ref(null);
|
const timer = ref(null);
|
||||||
|
|
||||||
@ -201,15 +179,6 @@ const confirmModalMessage = ref("");
|
|||||||
const confirmText = ref("Ya, Konfirmasi");
|
const confirmText = ref("Ya, Konfirmasi");
|
||||||
const cancelText = ref("Batal");
|
const cancelText = ref("Batal");
|
||||||
|
|
||||||
// QR Code generator
|
|
||||||
const qrCodeUrl = computed(() => {
|
|
||||||
if (selectedItem.value) {
|
|
||||||
const data = selectedItem.value.kode_item;
|
|
||||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed untuk statistik
|
// Computed untuk statistik
|
||||||
const totalWeight = computed(() => {
|
const totalWeight = computed(() => {
|
||||||
const total = filteredItems.value.reduce((sum, item) => {
|
const total = filteredItems.value.reduce((sum, item) => {
|
||||||
@ -250,13 +219,6 @@ const confirmDelete = async () => {
|
|||||||
await axios.delete(`/api/item/${selectedItem.value.id}`, {
|
await axios.delete(`/api/item/${selectedItem.value.id}`, {
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tampilkan alert sukses
|
|
||||||
alert.value = { success: `Item ${selectedItem.value.kode_item} berhasil dihapus.` };
|
|
||||||
|
|
||||||
// Refresh data
|
|
||||||
await refreshData();
|
|
||||||
|
|
||||||
// Tutup modal & popup
|
// Tutup modal & popup
|
||||||
showDeleteConfirm.value = false;
|
showDeleteConfirm.value = false;
|
||||||
closePopup();
|
closePopup();
|
||||||
@ -264,7 +226,6 @@ const confirmDelete = async () => {
|
|||||||
// Auto hide alert
|
// Auto hide alert
|
||||||
clearTimeout(timer.value);
|
clearTimeout(timer.value);
|
||||||
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Gagal menghapus item:", err.response?.data || err);
|
console.error("Gagal menghapus item:", err.response?.data || err);
|
||||||
alert.value = { error: err.response?.data?.message || "Gagal menghapus item. Silakan coba lagi." };
|
alert.value = { error: err.response?.data?.message || "Gagal menghapus item. Silakan coba lagi." };
|
||||||
@ -275,17 +236,16 @@ const confirmDelete = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const cancelDelete = () => {
|
const cancelDelete = () => {
|
||||||
showDeleteConfirm.value = false;
|
showDeleteConfirm.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveMove = async () => {
|
const saveMove = async () => {
|
||||||
if (!selectedTrayId.value || !selectedItem.value || isMoving.value) return;
|
if (!selectedTrayId.value || !selectedItem.value || isMoving.value) return;
|
||||||
|
|
||||||
errorMove.value = "";
|
errorMove.value = "";
|
||||||
isMoving.value = true;
|
isMoving.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.put(
|
await axios.put(
|
||||||
`/api/item/${selectedItem.value.id}`,
|
`/api/item/${selectedItem.value.id}`,
|
||||||
@ -301,14 +261,13 @@ const saveMove = async () => {
|
|||||||
// Tampilkan alert sukses
|
// Tampilkan alert sukses
|
||||||
const trayName = trays.value.find(t => t.id === selectedTrayId.value)?.nama;
|
const trayName = trays.value.find(t => t.id === selectedTrayId.value)?.nama;
|
||||||
alert.value = { success: `Item berhasil dipindahkan ke nampan "${trayName}"` };
|
alert.value = { success: `Item berhasil dipindahkan ke nampan "${trayName}"` };
|
||||||
|
|
||||||
await refreshData();
|
await refreshData();
|
||||||
closePopup();
|
closePopup();
|
||||||
|
|
||||||
// Auto hide alert
|
// Auto hide alert
|
||||||
clearTimeout(timer.value);
|
clearTimeout(timer.value);
|
||||||
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Gagal memindahkan item:", err.response?.data || err);
|
console.error("Gagal memindahkan item:", err.response?.data || err);
|
||||||
errorMove.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
|
errorMove.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
|
||||||
@ -331,62 +290,6 @@ const handleConfirmAction = async () => {
|
|||||||
closeConfirmModal();
|
closeConfirmModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fungsi utilitas
|
|
||||||
const printQR = () => {
|
|
||||||
if (qrCodeUrl.value && selectedItem.value) {
|
|
||||||
const printWindow = window.open('', '_blank');
|
|
||||||
const itemCode = selectedItem.value.kode_item;
|
|
||||||
|
|
||||||
printWindow.document.write(`
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Print QR Code - ${itemCode}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.qr-container {
|
|
||||||
border: 2px solid #ccc;
|
|
||||||
padding: 20px;
|
|
||||||
display: inline-block;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
.qr-img {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
.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>
|
|
||||||
`);
|
|
||||||
|
|
||||||
printWindow.document.close();
|
|
||||||
|
|
||||||
const img = printWindow.document.getElementById("qr-img");
|
|
||||||
img.onload = () => {
|
|
||||||
printWindow.focus();
|
|
||||||
printWindow.print();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const handleImageError = (event) => {
|
const handleImageError = (event) => {
|
||||||
event.target.style.display = 'none';
|
event.target.style.display = 'none';
|
||||||
};
|
};
|
||||||
@ -402,10 +305,10 @@ const refreshData = async () => {
|
|||||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter hanya item yang ada di brankas (id_nampan = null atau tidak ada)
|
// Filter hanya item yang ada di brankas (id_nampan = null atau tidak ada)
|
||||||
items.value = itemRes.data.filter(item => !item.id_nampan);
|
items.value = itemRes.data.filter(item => !item.id_nampan);
|
||||||
trays.value = trayRes.data;
|
trays.value = trayRes.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching data:", err);
|
console.error("Error fetching data:", err);
|
||||||
alert.value = { error: err.response?.data?.message || "Gagal mengambil data" };
|
alert.value = { error: err.response?.data?.message || "Gagal mengambil data" };
|
||||||
@ -421,9 +324,17 @@ onMounted(refreshData);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
from {
|
||||||
to { opacity: 1; transform: scale(1); }
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fadeIn {
|
.animate-fadeIn {
|
||||||
animation: fadeIn 0.25s ease-out forwards;
|
animation: fadeIn 0.25s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,9 +35,7 @@
|
|||||||
|
|
||||||
<!-- QR Code -->
|
<!-- QR Code -->
|
||||||
<div class="flex justify-center mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
<div class="p-2 border border-gray-300 rounded-lg">
|
<PrintBarcode :code="createdItem?.kode_item" :item="product"/>
|
||||||
<img :src="qrCodeUrl" alt="QR Code" class="w-36 h-36" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Item Info -->
|
<!-- Item Info -->
|
||||||
@ -53,10 +51,6 @@
|
|||||||
class="flex-1 px-6 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-lg transition-colors">
|
class="flex-1 px-6 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-lg transition-colors">
|
||||||
Selesai
|
Selesai
|
||||||
</button>
|
</button>
|
||||||
<button @click="printItem"
|
|
||||||
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>
|
|
||||||
<button @click="addNewItem"
|
<button @click="addNewItem"
|
||||||
class="flex-1 px-6 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors">
|
class="flex-1 px-6 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors">
|
||||||
Buat Lagi
|
Buat Lagi
|
||||||
@ -69,10 +63,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import InputSelect from './InputSelect.vue';
|
import InputSelect from './InputSelect.vue';
|
||||||
|
import PrintBarcode from './PrintBarcode.vue';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -99,15 +94,7 @@ const success = ref(false);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const createdItem = ref(null);
|
const createdItem = ref(null);
|
||||||
|
|
||||||
// QR Code generator - berdasarkan logika dari brankas list
|
// QR Code rendering/printing moved to PrintBarcode component
|
||||||
const qrCodeUrl = computed(() => {
|
|
||||||
if (createdItem.value && props.product) {
|
|
||||||
const kode_item = createdItem.value.kode_item;
|
|
||||||
const data = kode_item;
|
|
||||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
@ -174,60 +161,6 @@ const addNewItem = () => {
|
|||||||
createdItem.value = null;
|
createdItem.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fungsi print berdasarkan logika dari brankas list
|
|
||||||
const printItem = () => {
|
|
||||||
if (qrCodeUrl.value && createdItem.value && props.product) {
|
|
||||||
const printWindow = window.open('', '_blank');
|
|
||||||
const itemCode = createdItem.value.kode_item || createdItem.value.id;
|
|
||||||
|
|
||||||
printWindow.document.write(`
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Print QR Code - ${itemCode}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.qr-container {
|
|
||||||
border: 2px solid #ccc;
|
|
||||||
padding: 20px;
|
|
||||||
display: inline-block;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
.qr-img {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
.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>
|
|
||||||
`);
|
|
||||||
|
|
||||||
printWindow.document.close();
|
|
||||||
|
|
||||||
const img = printWindow.document.getElementById("qr-img");
|
|
||||||
img.onload = () => {
|
|
||||||
printWindow.focus();
|
|
||||||
printWindow.print();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// Reset state
|
// Reset state
|
||||||
|
|||||||
@ -2,9 +2,23 @@
|
|||||||
<ConfirmDeleteModal v-if="showDeleteModal" :isOpen="showDeleteModal" title="Konfirmasi"
|
<ConfirmDeleteModal v-if="showDeleteModal" :isOpen="showDeleteModal" title="Konfirmasi"
|
||||||
message="Yakin ingin menghapus item ini?" @confirm="hapusPesanan" @cancel="closeDeleteModal" />
|
message="Yakin ingin menghapus item ini?" @confirm="hapusPesanan" @cancel="closeDeleteModal" />
|
||||||
|
|
||||||
<!-- ==== TAMBAHAN: Struk Overlay ==== -->
|
<!-- Struk Input Overlay -->
|
||||||
<StrukOverlay v-if="showStruk" :isOpen="showStruk" :pesanan="pesanan" :total="total" @close="closeStruk" />
|
<StrukOverlay
|
||||||
<!-- ==== END TAMBAHAN ==== -->
|
v-if="showStruk"
|
||||||
|
:isOpen="showStruk"
|
||||||
|
:pesanan="pesanan"
|
||||||
|
:total="total"
|
||||||
|
@close="closeStruk"
|
||||||
|
@transaksi-saved="handleTransaksiSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Struk View (Print) Overlay -->
|
||||||
|
<StrukView
|
||||||
|
v-if="showStrukView"
|
||||||
|
:isOpen="showStrukView"
|
||||||
|
:transaksi="savedTransaksi"
|
||||||
|
@close="closeStrukView"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="p-2 sm:p-4">
|
<div class="p-2 sm:p-4">
|
||||||
<!-- Grid Form & Total -->
|
<!-- Grid Form & Total -->
|
||||||
@ -115,13 +129,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import InputField from "./InputField.vue";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import ConfirmDeleteModal from "./ConfirmDeleteModal.vue";
|
import ConfirmDeleteModal from "./ConfirmDeleteModal.vue";
|
||||||
import StrukOverlay from "./StrukOverlay.vue";
|
import StrukOverlay from "./StrukOverlay.vue";
|
||||||
|
import StrukView from "./StrukView.vue";
|
||||||
|
|
||||||
|
// Emit untuk komunikasi dengan parent
|
||||||
|
const emit = defineEmits(['transaksi-saved']);
|
||||||
|
|
||||||
const kodeItem = ref("");
|
const kodeItem = ref("");
|
||||||
const info = ref("");
|
const info = ref("");
|
||||||
@ -131,21 +147,21 @@ const hargaJualFormatted = ref("");
|
|||||||
const item = ref(null);
|
const item = ref(null);
|
||||||
const loadingItem = ref(false);
|
const loadingItem = ref(false);
|
||||||
const pesanan = ref([]);
|
const pesanan = ref([]);
|
||||||
const showDeleteModal = ref(false)
|
const showDeleteModal = ref(false);
|
||||||
const deleteIndex = ref(null)
|
const deleteIndex = ref(null);
|
||||||
|
|
||||||
const showStruk = ref(false);
|
const showStruk = ref(false);
|
||||||
|
const showStrukView = ref(false);
|
||||||
|
const savedTransaksi = ref(null);
|
||||||
|
|
||||||
let errorTimeout = null;
|
let errorTimeout = null;
|
||||||
let infoTimeout = null;
|
let infoTimeout = null;
|
||||||
|
|
||||||
// Format angka dengan pemisah ribuan
|
|
||||||
const formatNumber = (num) => {
|
const formatNumber = (num) => {
|
||||||
if (!num) return "";
|
if (!num) return "";
|
||||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Menghapus format dan mengambil angka asli
|
|
||||||
const unformatNumber = (str) => {
|
const unformatNumber = (str) => {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const cleaned = str.replace(/\./g, "");
|
const cleaned = str.replace(/\./g, "");
|
||||||
@ -153,14 +169,11 @@ const unformatNumber = (str) => {
|
|||||||
return isNaN(number) ? null : number;
|
return isNaN(number) ? null : number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler untuk format input harga
|
|
||||||
const formatHargaInput = (event) => {
|
const formatHargaInput = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
// Hapus semua karakter selain angka
|
|
||||||
const cleanValue = value.replace(/\D/g, "");
|
const cleanValue = value.replace(/\D/g, "");
|
||||||
|
|
||||||
if (cleanValue) {
|
if (cleanValue) {
|
||||||
// Format dengan pemisah ribuan
|
|
||||||
const formatted = formatNumber(cleanValue);
|
const formatted = formatNumber(cleanValue);
|
||||||
hargaJualFormatted.value = formatted;
|
hargaJualFormatted.value = formatted;
|
||||||
hargaJual.value = parseInt(cleanValue);
|
hargaJual.value = parseInt(cleanValue);
|
||||||
@ -170,7 +183,6 @@ const formatHargaInput = (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hanya izinkan angka saat mengetik
|
|
||||||
const onlyNumbers = (event) => {
|
const onlyNumbers = (event) => {
|
||||||
const char = String.fromCharCode(event.which);
|
const char = String.fromCharCode(event.which);
|
||||||
if (!/[0-9]/.test(char)) {
|
if (!/[0-9]/.test(char)) {
|
||||||
@ -196,11 +208,8 @@ const inputItem = async () => {
|
|||||||
});
|
});
|
||||||
item.value = response.data;
|
item.value = response.data;
|
||||||
hargaJual.value = item.value.produk.harga_jual;
|
hargaJual.value = item.value.produk.harga_jual;
|
||||||
// Format harga untuk tampilan
|
|
||||||
hargaJualFormatted.value = formatNumber(item.value.produk.harga_jual);
|
hargaJualFormatted.value = formatNumber(item.value.produk.harga_jual);
|
||||||
|
|
||||||
// console.log(item.value);
|
|
||||||
|
|
||||||
if (item.value.is_sold) {
|
if (item.value.is_sold) {
|
||||||
throw new Error("Item sudah terjual");
|
throw new Error("Item sudah terjual");
|
||||||
}
|
}
|
||||||
@ -231,8 +240,7 @@ const tambahItem = () => {
|
|||||||
if (!item.value || !hargaJual.value) {
|
if (!item.value || !hargaJual.value) {
|
||||||
error.value = "Scan atau masukkan kode item untuk dijual.";
|
error.value = "Scan atau masukkan kode item untuk dijual.";
|
||||||
if (kodeItem.value) {
|
if (kodeItem.value) {
|
||||||
error.value =
|
error.value = "Masukkan harga jual, atau input dari kode item lagi.";
|
||||||
"Masukkan harga jual, atau input dari kode item lagi.";
|
|
||||||
}
|
}
|
||||||
clearTimeout(errorTimeout);
|
clearTimeout(errorTimeout);
|
||||||
errorTimeout = setTimeout(() => {
|
errorTimeout = setTimeout(() => {
|
||||||
@ -241,14 +249,14 @@ const tambahItem = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// harga deal
|
item.value.kode_item = kodeItem.value;
|
||||||
item.value.kode_item = Number(kodeItem.value);
|
|
||||||
item.value.harga_deal = Number(hargaJual.value);
|
item.value.harga_deal = Number(hargaJual.value);
|
||||||
item.value.posisi = item.value.nampan ? item.value.nampan.nama : "Brankas";
|
item.value.posisi = item.value.nampan ? item.value.nampan.nama : "Brankas";
|
||||||
|
|
||||||
pesanan.value.push(item.value);
|
pesanan.value.push(item.value);
|
||||||
|
|
||||||
// Reset input fields
|
console.log("Pesanan +:", item.value);
|
||||||
|
|
||||||
kodeItem.value = "";
|
kodeItem.value = "";
|
||||||
hargaJual.value = null;
|
hargaJual.value = null;
|
||||||
hargaJualFormatted.value = "";
|
hargaJualFormatted.value = "";
|
||||||
@ -258,23 +266,22 @@ const tambahItem = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteModal = (index) => {
|
const openDeleteModal = (index) => {
|
||||||
deleteIndex.value = index
|
deleteIndex.value = index;
|
||||||
showDeleteModal.value = true
|
showDeleteModal.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const closeDeleteModal = () => {
|
const closeDeleteModal = () => {
|
||||||
showDeleteModal.value = false
|
showDeleteModal.value = false;
|
||||||
deleteIndex.value = null
|
deleteIndex.value = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const hapusPesanan = () => {
|
const hapusPesanan = () => {
|
||||||
if (deleteIndex.value !== null) {
|
if (deleteIndex.value !== null) {
|
||||||
pesanan.value.splice(deleteIndex.value, 1)
|
pesanan.value.splice(deleteIndex.value, 1);
|
||||||
}
|
}
|
||||||
closeDeleteModal()
|
closeDeleteModal();
|
||||||
}
|
};
|
||||||
|
|
||||||
// ==== MODIFIKASI: konfirmasiPenjualan sekarang menampilkan struk ====
|
|
||||||
const konfirmasiPenjualan = () => {
|
const konfirmasiPenjualan = () => {
|
||||||
if (pesanan.value.length === 0) {
|
if (pesanan.value.length === 0) {
|
||||||
error.value = "Belum ada item yang dipesan.";
|
error.value = "Belum ada item yang dipesan.";
|
||||||
@ -284,17 +291,37 @@ const konfirmasiPenjualan = () => {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(pesanan.value)
|
|
||||||
// Tampilkan struk overlay
|
|
||||||
showStruk.value = true;
|
showStruk.value = true;
|
||||||
};
|
};
|
||||||
// ==== END MODIFIKASI ====
|
|
||||||
|
|
||||||
// ==== TAMBAHAN: Fungsi untuk menutup struk ====
|
|
||||||
const closeStruk = () => {
|
const closeStruk = () => {
|
||||||
showStruk.value = false;
|
showStruk.value = false;
|
||||||
};
|
};
|
||||||
// ==== END TAMBAHAN ====
|
|
||||||
|
const closeStrukView = () => {
|
||||||
|
showStrukView.value = false;
|
||||||
|
savedTransaksi.value = null;
|
||||||
|
|
||||||
|
// Reset pesanan setelah menutup struk view
|
||||||
|
pesanan.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler ketika transaksi berhasil disimpan
|
||||||
|
const handleTransaksiSaved = (transaksiData) => {
|
||||||
|
// Tutup StrukOverlay
|
||||||
|
showStruk.value = false;
|
||||||
|
|
||||||
|
// Simpan data transaksi
|
||||||
|
savedTransaksi.value = transaksiData;
|
||||||
|
|
||||||
|
// Emit ke parent (Kasir.vue)
|
||||||
|
emit('transaksi-saved', transaksiData);
|
||||||
|
|
||||||
|
// Buka StrukView untuk print
|
||||||
|
setTimeout(() => {
|
||||||
|
showStrukView.value = true;
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
const total = computed(() => {
|
const total = computed(() => {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|
||||||
|
|
||||||
<!-- Summary Card -->
|
<!-- Summary Card -->
|
||||||
<div class="mt-3 bg-A border border-C rounded-lg p-3">
|
<div class="mt-3 bg-A border border-C rounded-lg p-3">
|
||||||
@ -51,7 +51,7 @@
|
|||||||
<td class="border border-gray-200 p-2 text-center">
|
<td class="border border-gray-200 p-2 text-center">
|
||||||
<button
|
<button
|
||||||
@click="lihatDetail(trx)"
|
@click="lihatDetail(trx)"
|
||||||
class="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-xs whitespace-nowrap"
|
class="px-3 py-1 bg-C text-D rounded-md hover:bg-blue-600 transition-colors text-xs whitespace-nowrap"
|
||||||
:disabled="isDetailLoading && selectedTransaksi.id === trx.id"
|
:disabled="isDetailLoading && selectedTransaksi.id === trx.id"
|
||||||
>
|
>
|
||||||
<span v-if="isDetailLoading && selectedTransaksi.id === trx.id">
|
<span v-if="isDetailLoading && selectedTransaksi.id === trx.id">
|
||||||
@ -72,7 +72,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>Menampilkan {{ pagination.from }} - {{ pagination.to }} dari {{ pagination.total }} transaksi hari ini</span>
|
<span>Menampilkan {{ pagination.from }} - {{ pagination.to }} dari {{ pagination.total }} transaksi hari ini</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@click="$emit('page-change', pagination.current_page - 1)"
|
@click="$emit('page-change', pagination.current_page - 1)"
|
||||||
@ -81,11 +81,11 @@
|
|||||||
>
|
>
|
||||||
← Prev
|
← Prev
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="px-2 text-gray-700">
|
<span class="px-2 text-gray-700">
|
||||||
{{ pagination.current_page }} / {{ pagination.last_page }}
|
{{ pagination.current_page }} / {{ pagination.last_page }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="$emit('page-change', pagination.current_page + 1)"
|
@click="$emit('page-change', pagination.current_page + 1)"
|
||||||
:disabled="pagination.current_page === pagination.last_page"
|
:disabled="pagination.current_page === pagination.last_page"
|
||||||
@ -101,7 +101,7 @@
|
|||||||
<div v-else-if="!loading" class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
<div v-else-if="!loading" class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
<div class="text-gray-500 space-y-3">
|
<div class="text-gray-500 space-y-3">
|
||||||
<svg class="w-16 h-16 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@ -219,4 +219,4 @@ const closeDetail = () => {
|
|||||||
isDetailOpen.value = false
|
isDetailOpen.value = false
|
||||||
selectedTransaksi.value = {}
|
selectedTransaksi.value = {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ const sizeClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleOverlayClick = () => {
|
const handleOverlayClick = () => {
|
||||||
if (clickOutside.value) {
|
if (props.clickOutside.value) {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
170
resources/js/components/PrintBarcode.vue
Normal file
170
resources/js/components/PrintBarcode.vue
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="p-2 border rounded-lg inline-block">
|
||||||
|
<img :src="barcodeUrl" alt="Barcode" class="w-36 h-36" id="barcode-img" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item.nama" class="mt-2 text-sm text-gray-700 font-medium">
|
||||||
|
{{ item.nama }}
|
||||||
|
(<span v-if="item.berat" class="mt-1 text-xs text-gray-500">{{ item.berat }} g</span>)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-3">
|
||||||
|
<button @click="printBarcode" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-400 transition">
|
||||||
|
<i class="fas fa-print mr-2"></i>Cetak
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
code: { type: String, required: true },
|
||||||
|
item: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const barcodeUrl = computed(() => {
|
||||||
|
if (!props.code) return '';
|
||||||
|
// return `https://bwipjs-api.metafloor.com/?bcid=code128&text=${encodeURIComponent(props.code)}&scale=2`;
|
||||||
|
return `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(props.code)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const printBarcode = () => {
|
||||||
|
if (!barcodeUrl.value || !props.code) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
const kode = props.code || 'N/A';
|
||||||
|
const nama = props.item.nama || 'N/A';
|
||||||
|
const berat = props.item.berat ? `${props.item.berat} g` : '';
|
||||||
|
const kadar = props.item.kadar ? `${props.item.kadar} K` : '';
|
||||||
|
const harga = props.item.harga_jual ? `Rp${props.item.harga_jual.toLocaleString('id-ID')},00` : '';
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Print Barcode - ${kode}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: 25mm 38mm;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
width: 25mm;
|
||||||
|
height: 38mm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
width: 25mm;
|
||||||
|
height: 38mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-side {
|
||||||
|
width: 12.5mm;
|
||||||
|
height: 38mm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-content {
|
||||||
|
position: absolute;
|
||||||
|
width: fit-content;
|
||||||
|
height: 12.5mm;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transform-origin: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-img {
|
||||||
|
height: 10mm;
|
||||||
|
width: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5mm;
|
||||||
|
font-size: 8pt;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-side {
|
||||||
|
width: 12.5mm;
|
||||||
|
height: 38mm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-size: 8pt;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
position: absolute;
|
||||||
|
width: 34mm;
|
||||||
|
text-align: center;
|
||||||
|
transform: rotate(270deg);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="label">
|
||||||
|
<!-- Sisi Kiri: Barcode + Info -->
|
||||||
|
<div class="left-side">
|
||||||
|
<div class="left-content">
|
||||||
|
<img id="barcode-img" class="barcode-img" src="${barcodeUrl.value}" alt="Barcode" />
|
||||||
|
<div class="info-box">
|
||||||
|
${harga ? `<div>${harga}</div>` : ''}
|
||||||
|
${berat ? `<div>Berat: ${berat}</div>` : ''}
|
||||||
|
${kadar ? `<div>Kadar: ${kadar}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-side">
|
||||||
|
<div class="item-name">${nama}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
printWindow.document.close();
|
||||||
|
|
||||||
|
// ✅ Tunggu gambar selesai load
|
||||||
|
const checkImageLoaded = () => {
|
||||||
|
const img = printWindow.document.getElementById('barcode-img');
|
||||||
|
if (img && img.complete) {
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
} else {
|
||||||
|
setTimeout(checkImageLoaded, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkImageLoaded();
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
@ -18,7 +18,7 @@
|
|||||||
<p class="flex items-center gap-2">
|
<p class="flex items-center gap-2">
|
||||||
<i class="fab fa-whatsapp text-green-500 text-xl"></i> 08158851178
|
<i class="fab fa-whatsapp text-green-500 text-xl"></i> 08158851178
|
||||||
</p>
|
</p>
|
||||||
<p class=" text-sm">{{ generateTransactionCode() }}</p>
|
<p class="text-sm">TRSXXXXXXXXXXXX</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute inset-x-0 top-[-48px] flex flex-col items-center">
|
<div class="absolute inset-x-0 top-[-48px] flex flex-col items-center">
|
||||||
@ -64,7 +64,6 @@
|
|||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- Item rows dengan dynamic height -->
|
|
||||||
<tr v-for="(item, index) in props.pesanan" :key="index"
|
<tr v-for="(item, index) in props.pesanan" :key="index"
|
||||||
class="text-center"
|
class="text-center"
|
||||||
:style="getRowStyle()">
|
:style="getRowStyle()">
|
||||||
@ -95,9 +94,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<!-- Bagian bawah -->
|
|
||||||
<div class="flex text-sm mt-2">
|
<div class="flex text-sm mt-2">
|
||||||
<!-- PERHATIAN -->
|
|
||||||
<div class="w-[40%] p-2 text-left">
|
<div class="w-[40%] p-2 text-left">
|
||||||
<p class="font-semibold">PERHATIAN</p>
|
<p class="font-semibold">PERHATIAN</p>
|
||||||
<ol class="list-decimal ml-4 text-xs space-y-1">
|
<ol class="list-decimal ml-4 text-xs space-y-1">
|
||||||
@ -109,17 +106,14 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SALES -->
|
|
||||||
<div class="w-[20%] p-2 flex flex-col items-center justify-center">
|
<div class="w-[20%] p-2 flex flex-col items-center justify-center">
|
||||||
<p><strong>Hormat Kami</strong></p>
|
<p><strong>Hormat Kami</strong></p>
|
||||||
<inputSelect v-model="selectedSales" :options="salesOptions"
|
<inputSelect v-model="selectedSales" :options="salesOptions" placeholder="Pilih Sales"
|
||||||
class="mt-16 text-sm rounded bg-B cursor-pointer !w-[160px] text-center [option]:text-left" />
|
class="mt-16 text-sm rounded bg-B cursor-pointer !w-[160px] text-center [option]:text-left" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ONGKOS & TOTAL -->
|
|
||||||
<div class="ml-auto w-[25%] p-2 flex flex-col justify-between">
|
<div class="ml-auto w-[25%] p-2 flex flex-col justify-between">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Ongkos bikin -->
|
|
||||||
<div class="flex items-start justify-between ">
|
<div class="flex items-start justify-between ">
|
||||||
<div class="flex flex-col ">
|
<div class="flex flex-col ">
|
||||||
<p class="font-semibold">Ongkos bikin</p>
|
<p class="font-semibold">Ongkos bikin</p>
|
||||||
@ -132,7 +126,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total -->
|
|
||||||
<div class="flex items-center justify-between -mt-4">
|
<div class="flex items-center justify-between -mt-4">
|
||||||
<p class="font-semibold">Total Harga</p>
|
<p class="font-semibold">Total Harga</p>
|
||||||
<div class="flex items-center w-40">
|
<div class="flex items-center w-40">
|
||||||
@ -144,13 +137,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tombol -->
|
|
||||||
<div class="flex justify-end gap-2 mt-4">
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
<button @click="$emit('close')" class="bg-gray-400 text-white px-6 py-2 rounded">
|
<button @click="$emit('close')" class="bg-gray-400 text-white px-6 py-2 rounded">
|
||||||
Batal
|
Batal
|
||||||
</button>
|
</button>
|
||||||
<button @click="handleSimpan" class="bg-C text-white px-6 py-2 rounded">
|
<button @click="handleSimpan" :disabled="isSaving" class="bg-C text-white px-6 py-2 rounded disabled:opacity-50">
|
||||||
Simpan
|
{{ isSaving ? 'Menyimpan...' : 'Simpan' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -162,13 +154,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Simple Toast Alert -->
|
|
||||||
<div v-if="showToast"
|
<div v-if="showToast"
|
||||||
class="fixed top-4 left-1/2 transform -translate-x-1/2 z-[10001]
|
class="fixed top-4 left-1/2 transform -translate-x-1/2 z-[10001]
|
||||||
transition-all duration-300 ease-in-out"
|
transition-all duration-300 ease-in-out"
|
||||||
:class="toastClasses">
|
:class="toastClasses">
|
||||||
<div class="flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg max-w-sm">
|
<div class="flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg max-w-sm">
|
||||||
<!-- Icon -->
|
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg v-if="toastType === 'error'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
<svg v-if="toastType === 'error'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
@ -180,8 +170,6 @@
|
|||||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message -->
|
|
||||||
<p class="text-sm font-medium">{{ toastMessage }}</p>
|
<p class="text-sm font-medium">{{ toastMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -198,25 +186,15 @@ import logo_visa from '@/../images/logo_visa.png'
|
|||||||
import logo_mandiri from '@/../images/logo_mandiri.png'
|
import logo_mandiri from '@/../images/logo_mandiri.png'
|
||||||
import inputField from '@/components/InputField.vue'
|
import inputField from '@/components/InputField.vue'
|
||||||
import inputSelect from '@/components/InputSelect.vue'
|
import inputSelect from '@/components/InputSelect.vue'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isOpen: {
|
isOpen: Boolean,
|
||||||
type: Boolean,
|
pesanan: Array,
|
||||||
default: false,
|
total: Number
|
||||||
},
|
|
||||||
pesanan: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
total: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'confirm'])
|
const emit = defineEmits(['close', 'confirm', 'transaksi-saved'])
|
||||||
|
|
||||||
const namaPembeli = ref('')
|
const namaPembeli = ref('')
|
||||||
const nomorTelepon = ref('')
|
const nomorTelepon = ref('')
|
||||||
@ -225,63 +203,37 @@ const ongkosBikin = ref(0)
|
|||||||
const selectedSales = ref(null)
|
const selectedSales = ref(null)
|
||||||
const salesOptions = ref([])
|
const salesOptions = ref([])
|
||||||
const ongkosBikinFormatted = ref("")
|
const ongkosBikinFormatted = ref("")
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
// Simple Toast State
|
|
||||||
const showToast = ref(false)
|
const showToast = ref(false)
|
||||||
const toastType = ref('error') // 'error', 'success', 'info'
|
const toastType = ref('error')
|
||||||
const toastMessage = ref('')
|
const toastMessage = ref('')
|
||||||
|
|
||||||
|
// 🧾 kode transaksi tetap
|
||||||
|
const transactionCode = ref('')
|
||||||
|
|
||||||
const toastClasses = computed(() => {
|
const toastClasses = computed(() => {
|
||||||
const baseClasses = 'text-white'
|
const base = 'text-white'
|
||||||
const typeClasses = {
|
const type = {
|
||||||
error: 'bg-red-500',
|
error: 'bg-red-500',
|
||||||
success: 'bg-green-500',
|
success: 'bg-green-500',
|
||||||
info: 'bg-blue-500'
|
info: 'bg-blue-500'
|
||||||
}
|
}
|
||||||
return `${baseClasses} ${typeClasses[toastType.value]}`
|
return `${base} ${type[toastType.value]}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const grandTotal = computed(() => {
|
const grandTotal = computed(() => props.total + (ongkosBikin.value || 0))
|
||||||
return props.total + (ongkosBikin.value || 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fungsi untuk menentukan style row berdasarkan jumlah item
|
const getRowStyle = () => props.pesanan.length === 1 ? { height: '126px' } : { height: '63px' }
|
||||||
const getRowStyle = () => {
|
const getImageClass = () => props.pesanan.length === 1 ? 'w-25 h-25' : 'w-12 h-12'
|
||||||
if (props.pesanan.length === 1) {
|
const getTextClass = () => props.pesanan.length === 1 ? 'text-lg font-medium' : 'text-sm'
|
||||||
return { height: '126px' } // 2x lipat dari tinggi normal (48px)
|
|
||||||
}
|
|
||||||
return { height: '63px' } // Tinggi normal
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Fungsi untuk menentukan class gambar berdasarkan jumlah item
|
|
||||||
const getImageClass = () => {
|
|
||||||
if (props.pesanan.length === 1) {
|
|
||||||
return 'w-25 h-25' // 2x lipat dari ukuran normal (w-10 h-10)
|
|
||||||
}
|
|
||||||
return 'w-12 h-12' // Ukuran normal
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fungsi untuk menentukan class text berdasarkan jumlah item
|
|
||||||
const getTextClass = () => {
|
|
||||||
if (props.pesanan.length === 1) {
|
|
||||||
return 'text-lg font-medium' // Text lebih besar untuk single item
|
|
||||||
}
|
|
||||||
return 'text-sm' // Text normal
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCurrentDate = () => {
|
const getCurrentDate = () => {
|
||||||
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu']
|
const days = ['Minggu','Senin','Selasa','Rabu','Kamis','Jumat','Sabtu']
|
||||||
const months = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
|
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const dayName = days[now.getDay()]
|
|
||||||
const day = String(now.getDate()).padStart(2, '0')
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
const month = months[now.getMonth()]
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
const year = now.getFullYear()
|
return `${days[now.getDay()]}, ${day}-${month}-${now.getFullYear()}`
|
||||||
|
|
||||||
return `${dayName}, ${day}-${month}-${year}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateTransactionCode = () => {
|
const generateTransactionCode = () => {
|
||||||
@ -290,110 +242,80 @@ const generateTransactionCode = () => {
|
|||||||
return `TRS-${timestamp}`
|
return `TRS-${timestamp}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple Toast Function
|
|
||||||
const showSimpleToast = (type, message, duration = 3000) => {
|
const showSimpleToast = (type, message, duration = 3000) => {
|
||||||
toastType.value = type
|
toastType.value = type
|
||||||
toastMessage.value = message
|
toastMessage.value = message
|
||||||
showToast.value = true
|
showToast.value = true
|
||||||
|
setTimeout(() => (showToast.value = false), duration)
|
||||||
setTimeout(() => {
|
|
||||||
showToast.value = false
|
|
||||||
}, duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchSales = async () => {
|
const fetchSales = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/sales', {
|
const res = await axios.get('/api/sales', {
|
||||||
headers: {
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` }
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
salesOptions.value = res.data.map(s => ({ value: s.id, label: s.nama }))
|
||||||
salesOptions.value = response.data.map(sales => ({
|
selectedSales.value = ''
|
||||||
value: sales.id,
|
} catch (e) {
|
||||||
label: sales.nama
|
console.error('Error fetching sales:', e)
|
||||||
}))
|
|
||||||
|
|
||||||
if (salesOptions.value.length > 0) {
|
|
||||||
selectedSales.value = salesOptions.value[0].value
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching sales:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🟢 Generate kode hanya saat menyimpan transaksi
|
||||||
const handleSimpan = () => {
|
const handleSimpan = () => {
|
||||||
if (!namaPembeli.value.trim()) {
|
if (!namaPembeli.value.trim()) return showSimpleToast('error', 'Nama pembeli harus diisi!')
|
||||||
showSimpleToast('error', 'Nama pembeli harus diisi!')
|
if (!nomorTelepon.value.trim()) return showSimpleToast('error', 'Nomor telepon harus diisi!')
|
||||||
return
|
if (!alamat.value.trim()) return showSimpleToast('error', 'Alamat harus diisi!')
|
||||||
}
|
if (!selectedSales.value) return showSimpleToast('error', 'Sales harus dipilih!')
|
||||||
|
|
||||||
if (!nomorTelepon.value.trim()) {
|
// Kode transaksi dibuat hanya saat simpan
|
||||||
showSimpleToast('error', 'Nomor telepon harus diisi!')
|
transactionCode.value = generateTransactionCode()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!alamat.value.trim()) {
|
|
||||||
showSimpleToast('error', 'Alamat harus diisi!')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedSales.value) {
|
|
||||||
showSimpleToast('error', 'Sales harus dipilih!')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
simpanTransaksi({
|
simpanTransaksi({
|
||||||
|
kode_transaksi: transactionCode.value,
|
||||||
id_sales: selectedSales.value,
|
id_sales: selectedSales.value,
|
||||||
nama_pembeli: namaPembeli.value,
|
nama_pembeli: namaPembeli.value,
|
||||||
no_hp: nomorTelepon.value,
|
no_hp: nomorTelepon.value,
|
||||||
alamat: alamat.value,
|
alamat: alamat.value,
|
||||||
ongkos_bikin: ongkosBikin.value || 0, // Pastikan nama field benar
|
ongkos_bikin: ongkosBikin.value || 0,
|
||||||
total_harga: grandTotal.value,
|
total_harga: grandTotal.value,
|
||||||
items: props.pesanan
|
items: props.pesanan
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const simpanTransaksi = async (dataTransaksi) => {
|
const simpanTransaksi = async (dataTransaksi) => {
|
||||||
// console.log('Data transaksi yang akan disimpan:', dataTransaksi);
|
if (isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/transaksi', dataTransaksi, {
|
const res = await axios.post('/api/transaksi', dataTransaksi, {
|
||||||
headers: {
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
})
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
showSimpleToast('success', 'Transaksi berhasil disimpan!', 2000)
|
showSimpleToast('success', 'Transaksi berhasil disimpan!', 2000)
|
||||||
|
|
||||||
// Delay untuk memberikan waktu user membaca notifikasi
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit('close');
|
emit('transaksi-saved', res.data)
|
||||||
window.location.reload();
|
emit('close')
|
||||||
}, 2200);
|
}, 2200)
|
||||||
|
} catch (err) {
|
||||||
} catch (error) {
|
const msg = err.response?.data?.message || err.message || 'Terjadi kesalahan saat menyimpan transaksi'
|
||||||
console.error('Error saving transaksi:', error);
|
showSimpleToast('error', `Error: ${msg}`, 4000)
|
||||||
const errorMessage = error.response?.data?.message || error.message || 'Terjadi kesalahan saat menyimpan transaksi';
|
} finally {
|
||||||
showSimpleToast('error', `Error: ${errorMessage}`, 4000);
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.isOpen) {
|
if (props.isOpen) fetchSales()
|
||||||
fetchSales()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatInput(e) {
|
function formatInput(e) {
|
||||||
let value = e.target.value.replace(/\D/g, "");
|
let val = e.target.value.replace(/\D/g, "")
|
||||||
ongkosBikin.value = value ? parseInt(value, 10) : null;
|
ongkosBikin.value = val ? parseInt(val, 10) : null
|
||||||
ongkosBikinFormatted.value = value
|
ongkosBikinFormatted.value = val ? new Intl.NumberFormat("id-ID").format(val) : ""
|
||||||
? new Intl.NumberFormat("id-ID").format(value)
|
|
||||||
: "";
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||||
|
|
||||||
|
|||||||
@ -73,19 +73,16 @@
|
|||||||
<span v-if="item.harga_deal && item.harga_deal > 0">1</span>
|
<span v-if="item.harga_deal && item.harga_deal > 0">1</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="flex items-center gap-2 p-2 border-r border-D" :style="getRowStyle()">
|
<td class="flex items-center gap-2 p-2 border-r border-D" :style="getRowStyle()">
|
||||||
<template v-if="item.produk?.foto?.[0]?.url">
|
<img
|
||||||
<img :src="item.produk.foto[0].url" :class="getImageClass()" class="object-cover rounded" />
|
:src="item.produk?.foto?.[0]?.url || 'https://via.placeholder.com/50x50?text=No+Img'"
|
||||||
</template>
|
:class="getImageClass()"
|
||||||
<template v-else-if="item.produk?.nama">
|
class="object-cover rounded"
|
||||||
<div :class="getImageClass() + ' bg-gray-200 rounded flex items-center justify-center'">
|
/>
|
||||||
<span class="text-xs text-gray-500">IMG</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
<span :class="getTextClass()">{{ item.produk?.nama || '' }}</span>
|
||||||
<template v-else>
|
</td>
|
||||||
<div :class="getImageClass()"></div>
|
|
||||||
</template>
|
|
||||||
<span :class="getTextClass()">{{ item.produk?.nama || '' }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="border-r border-D">
|
<td class="border-r border-D">
|
||||||
<span v-if="item.produk?.nama">{{ item.posisi_asal || 'Brankas' }}</span>
|
<span v-if="item.produk?.nama">{{ item.posisi_asal || 'Brankas' }}</span>
|
||||||
</td>
|
</td>
|
||||||
@ -106,7 +103,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="flex text-sm mt-2">
|
<div class="flex text-sm mt-2">
|
||||||
|
|
||||||
<div class="w-[40%] p-2 text-left">
|
<div class="w-[40%] p-2 text-left">
|
||||||
<p class="font-semibold">PERHATIAN</p>
|
<p class="font-semibold">PERHATIAN</p>
|
||||||
<ol class="list-decimal ml-4 text-xs space-y-1">
|
<ol class="list-decimal ml-4 text-xs space-y-1">
|
||||||
@ -206,7 +203,7 @@ const formatDate = (dateString) => {
|
|||||||
const day = String(date.getDate()).padStart(2, '0')
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
const month = months[date.getMonth()]
|
const month = months[date.getMonth()]
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
return `${dayName}/${day}-${month}-${year}`
|
return `${dayName}, ${day}-${month}-${year}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsWithMinimal = computed(() => {
|
const itemsWithMinimal = computed(() => {
|
||||||
@ -261,30 +258,40 @@ const formatNumber = (number) => {
|
|||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@page {
|
@page {
|
||||||
size: A4; /* atau '80mm 200mm' kalau thermal */
|
size: A4;
|
||||||
margin: Minimum;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sembunyikan semua elemen di luar print-area */
|
/* Sembunyikan semua elemen di luar print-area */
|
||||||
body * {
|
body * {
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-area * {
|
.print-area * {
|
||||||
visibility: visible !important;
|
visibility: visible !important;
|
||||||
-webkit-print-color-adjust: exact !important;
|
-webkit-print-color-adjust: exact !important;
|
||||||
print-color-adjust: exact !important;
|
print-color-adjust: exact !important;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-area {
|
.print-area {
|
||||||
position: absolute;
|
position: fixed !important;
|
||||||
top: 0;
|
top: 0 !important;
|
||||||
left: 0;
|
left: 0 !important;
|
||||||
width: 1224px;
|
width: 1224px;
|
||||||
height: 528px;
|
height: 528px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transform: scale(0.6673);
|
transform: scale(0.6673);
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
|
page-break-after: avoid !important;
|
||||||
|
page-break-inside: avoid !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hilangkan tombol tutup & print */
|
/* Hilangkan tombol tutup & print */
|
||||||
@ -293,4 +300,3 @@ const formatNumber = (number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@ -86,24 +86,7 @@
|
|||||||
<!-- Pop-up pindah item -->
|
<!-- Pop-up pindah item -->
|
||||||
<div v-if="isPopupVisible" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
<div v-if="isPopupVisible" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative">
|
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative">
|
||||||
<div class="flex justify-center mb-2">
|
<PrintBarcode :code="selectedItem.kode_item" :item="selectedItem.produk" />
|
||||||
<div class="p-2 border rounded-lg">
|
|
||||||
<img :src="qrCodeUrl" alt="QR Code" class="w-36 h-36" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center text-D font-bold text-lg">
|
|
||||||
{{ selectedItem.kode_item }}
|
|
||||||
</div>
|
|
||||||
<div class="text-center text-gray-700 font-medium mb-3">
|
|
||||||
{{ selectedItem.produk.nama }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dropdown -->
|
<!-- Dropdown -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@ -148,6 +131,7 @@ import { ref, onMounted, computed } from "vue";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import InputSelect from "./InputSelect.vue";
|
import InputSelect from "./InputSelect.vue";
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
|
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
|
||||||
|
import PrintBarcode from './PrintBarcode.vue';
|
||||||
|
|
||||||
const isAdmin = localStorage.getItem("role") === "owner";
|
const isAdmin = localStorage.getItem("role") === "owner";
|
||||||
|
|
||||||
@ -167,68 +151,6 @@ const selectedItem = ref(null);
|
|||||||
const selectedTrayId = ref("");
|
const selectedTrayId = ref("");
|
||||||
const showDeleteConfirm = ref(false);
|
const showDeleteConfirm = ref(false);
|
||||||
|
|
||||||
// QR Code generator
|
|
||||||
const qrCodeUrl = computed(() => {
|
|
||||||
if (selectedItem.value) {
|
|
||||||
const data = selectedItem.value.kode_item;
|
|
||||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const printQR = () => {
|
|
||||||
if (qrCodeUrl.value && selectedItem.value) {
|
|
||||||
const printWindow = window.open('', '_blank');
|
|
||||||
const itemCode = selectedItem.value.kode_item;
|
|
||||||
|
|
||||||
printWindow.document.write(`
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Print QR Code - ${itemCode}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.qr-container {
|
|
||||||
border: 2px solid #ccc;
|
|
||||||
padding: 20px;
|
|
||||||
display: inline-block;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
.qr-img {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
.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>
|
|
||||||
`);
|
|
||||||
|
|
||||||
printWindow.document.close();
|
|
||||||
|
|
||||||
const img = printWindow.document.getElementById("qr-img");
|
|
||||||
img.onload = () => {
|
|
||||||
printWindow.focus();
|
|
||||||
printWindow.print();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!selectedItem.value) return;
|
if (!selectedItem.value) return;
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="font-serif italic text-[25px] text-D">BRANKAS</p>
|
<p class="font-serif italic text-[25px] text-D">BRANKAS</p>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<div class="w-full sm:w-64 my-3">
|
<div class="w-full md:w-64 my-3 mb-9">
|
||||||
<searchbar v-model:search="searchQuery"/>
|
<searchbar v-model:search="searchQuery"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -15,7 +15,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import mainLayout from '../layouts/mainLayout.vue'
|
import mainLayout from '../layouts/mainLayout.vue'
|
||||||
import searchbar from '../components/searchbar.vue';
|
import searchbar from '../components/Searchbar.vue';
|
||||||
import BrankasList from '../components/BrankasList.vue';
|
import BrankasList from '../components/BrankasList.vue';
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -54,9 +54,7 @@
|
|||||||
|
|
||||||
<div class="mb-3 flex flex-row w-full gap-3">
|
<div class="mb-3 flex flex-row w-full gap-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="block text-D mb-1"
|
<label class="block text-D mb-1">Harga per Gram</label>
|
||||||
>Harga per Gram</label
|
|
||||||
>
|
|
||||||
<InputField
|
<InputField
|
||||||
v-model="form.harga_per_gram"
|
v-model="form.harga_per_gram"
|
||||||
type="number"
|
type="number"
|
||||||
@ -81,7 +79,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="block text-D mb-1">Foto</label>
|
<label class="block text-D mb-1">Foto</label>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3 relative">
|
||||||
<!-- Existing Images -->
|
<!-- Existing Images -->
|
||||||
<div
|
<div
|
||||||
v-for="(image, index) in uploadedImages"
|
v-for="(image, index) in uploadedImages"
|
||||||
@ -110,24 +108,85 @@
|
|||||||
<!-- Upload Button -->
|
<!-- Upload Button -->
|
||||||
<div
|
<div
|
||||||
v-if="uploadedImages.length < 6"
|
v-if="uploadedImages.length < 6"
|
||||||
@drop="handleDrop"
|
class="relative aspect-square"
|
||||||
@dragover.prevent
|
|
||||||
@dragenter.prevent="isDragging = true"
|
|
||||||
@dragleave.prevent="isDragging = false"
|
|
||||||
@click="triggerFileInput"
|
|
||||||
class="aspect-square bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-D hover:bg-blue-50 transition-colors group"
|
|
||||||
:class="{
|
|
||||||
'border-blue-400 bg-blue-50': isDragging,
|
|
||||||
'cursor-not-allowed opacity-50': uploadLoading,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div
|
||||||
<div
|
@drop="handleDrop"
|
||||||
v-if="!uploadLoading"
|
@dragover.prevent
|
||||||
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2 group-hover:bg-D transition-colors"
|
@dragenter.prevent="isDragging = true"
|
||||||
|
@dragleave.prevent="isDragging = false"
|
||||||
|
@click="toggleUploadMenu"
|
||||||
|
class="w-full h-full bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-D hover:bg-blue-50 transition-colors group"
|
||||||
|
:class="{
|
||||||
|
'border-blue-400 bg-blue-50': isDragging,
|
||||||
|
'cursor-not-allowed opacity-50': uploadLoading,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
v-if="!uploadLoading"
|
||||||
|
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2 group-hover:bg-D transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="animate-spin w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-600 font-medium"
|
||||||
|
v-html="
|
||||||
|
uploadLoading
|
||||||
|
? 'Uploading...'
|
||||||
|
: 'Unggah<br/>Foto'
|
||||||
|
"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown Menu -->
|
||||||
|
<div
|
||||||
|
v-if="showUploadMenu"
|
||||||
|
class="absolute top-full left-0 mt-2 w-60 bg-white border border-gray-200 rounded-lg shadow-lg z-20"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="triggerFileUpload"
|
||||||
|
class="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-100"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-6 h-6 text-white"
|
class="w-5 h-5 text-gray-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -136,42 +195,50 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
<div>
|
||||||
<div
|
<div class="font-medium text-gray-900">
|
||||||
v-else
|
Upload dari File
|
||||||
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2"
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Pilih foto dari galeri
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="openCameraModal"
|
||||||
|
class="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="animate-spin w-6 h-6 text-white"
|
class="w-5 h-5 text-gray-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
<path
|
||||||
class="opacity-75"
|
stroke-linecap="round"
|
||||||
fill="currentColor"
|
stroke-linejoin="round"
|
||||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
stroke-width="2"
|
||||||
></path>
|
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
<div>
|
||||||
<p
|
<div class="font-medium text-gray-900">
|
||||||
class="text-xs text-gray-600 font-medium"
|
Ambil dari Kamera
|
||||||
v-html="
|
</div>
|
||||||
uploadLoading
|
<div class="text-sm text-gray-500">
|
||||||
? 'Uploading...'
|
Foto langsung dengan kamera
|
||||||
: 'Unggah<br/>Foto'
|
</div>
|
||||||
"
|
</div>
|
||||||
></p>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -214,6 +281,43 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div
|
||||||
|
v-if="showUploadMenu"
|
||||||
|
@click="showUploadMenu = false"
|
||||||
|
class="fixed inset-0 z-10"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Camera Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showCamera"
|
||||||
|
class="fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]"
|
||||||
|
>
|
||||||
|
<div class="bg-white w-[480px] rounded-lg shadow-lg p-4 relative">
|
||||||
|
<video
|
||||||
|
ref="video"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
class="w-full h-64 bg-black rounded"
|
||||||
|
></video>
|
||||||
|
<canvas ref="canvas" class="hidden"></canvas>
|
||||||
|
<div class="mt-3 flex justify-between">
|
||||||
|
<button
|
||||||
|
@click="closeCamera"
|
||||||
|
class="px-4 py-2 bg-gray-400 text-white rounded"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="capturePhoto"
|
||||||
|
class="px-4 py-2 bg-blue-500 text-white rounded"
|
||||||
|
>
|
||||||
|
Ambil Foto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</mainLayout>
|
</mainLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -225,7 +329,6 @@ import mainLayout from "../layouts/mainLayout.vue";
|
|||||||
import InputField from "../components/InputField.vue";
|
import InputField from "../components/InputField.vue";
|
||||||
import InputSelect from "../components/InputSelect.vue";
|
import InputSelect from "../components/InputSelect.vue";
|
||||||
import CreateItemModal from "../components/CreateItemModal.vue";
|
import CreateItemModal from "../components/CreateItemModal.vue";
|
||||||
import { errorMessages } from "@vue/compiler-core";
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -248,6 +351,11 @@ const uploadLoading = ref(false);
|
|||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
|
const showUploadMenu = ref(false);
|
||||||
|
const showCamera = ref(false);
|
||||||
|
const video = ref(null);
|
||||||
|
const canvas = ref(null);
|
||||||
|
let stream = null;
|
||||||
|
|
||||||
const openItemModal = ref(false);
|
const openItemModal = ref(false);
|
||||||
const editedProduct = ref(null);
|
const editedProduct = ref(null);
|
||||||
@ -272,31 +380,37 @@ const calculateHargaJual = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadKategori = async () => {
|
const loadKategori = async () => {
|
||||||
const response = await axios.get("/api/kategori", {
|
try {
|
||||||
headers: {
|
const response = await axios.get("/api/kategori", {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
});
|
},
|
||||||
category.value = response.data.map((c) => ({ value: c.id, label: c.nama }));
|
});
|
||||||
|
category.value = response.data.map((c) => ({ value: c.id, label: c.nama }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading categories:", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadProduk = async () => {
|
const loadProduk = async () => {
|
||||||
const response = await axios.get(`/api/produk/edit/${productId}`, {
|
try {
|
||||||
headers: {
|
const response = await axios.get(`/api/produk/edit/${productId}`, {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
});
|
},
|
||||||
const produk = response.data;
|
});
|
||||||
// console.log(produk);
|
const produk = response.data;
|
||||||
|
form.value = {
|
||||||
form.value = {
|
nama: produk.nama,
|
||||||
nama: produk.nama,
|
id_kategori: produk.id_kategori,
|
||||||
id_kategori: produk.id_kategori,
|
berat: produk.berat,
|
||||||
berat: produk.berat,
|
kadar: produk.kadar,
|
||||||
kadar: produk.kadar,
|
harga_per_gram: produk.harga_per_gram,
|
||||||
harga_per_gram: produk.harga_per_gram,
|
harga_jual: produk.harga_jual,
|
||||||
harga_jual: produk.harga_jual,
|
};
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error("Error loading product:", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFoto = async () => {
|
const loadFoto = async () => {
|
||||||
@ -307,20 +421,23 @@ const loadFoto = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
uploadedImages.value = response.data;
|
uploadedImages.value = response.data;
|
||||||
// console.log(uploadedImages.value);
|
} catch (error) {
|
||||||
} catch (e) {
|
console.error("Error loading photos:", error);
|
||||||
console.error(e);
|
|
||||||
|
|
||||||
uploadError.value = "Gagal memuat foto";
|
uploadError.value = "Gagal memuat foto";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerFileInput = () => {
|
const toggleUploadMenu = () => {
|
||||||
if (!uploadLoading.value && uploadedImages.value.length < 6) {
|
if (!uploadLoading.value && uploadedImages.value.length < 6) {
|
||||||
fileInput.value?.click();
|
showUploadMenu.value = !showUploadMenu.value;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const triggerFileUpload = () => {
|
||||||
|
showUploadMenu.value = false;
|
||||||
|
fileInput.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e) => {
|
const handleFileSelect = (e) => {
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
uploadFiles(files);
|
uploadFiles(files);
|
||||||
@ -335,60 +452,47 @@ const handleDrop = (e) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const uploadFiles = async (files) => {
|
const uploadFiles = async (files) => {
|
||||||
uploadError.value = '';
|
uploadError.value = '';
|
||||||
|
if (uploadedImages.value.length + files.length > 6) {
|
||||||
if (uploadedImages.value.length + files.length > 6) {
|
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
|
||||||
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
const validFiles = files.filter(file => {
|
||||||
if (!isValidSize) {
|
const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
|
||||||
uploadError.value = 'Ukuran file maksimal 2MB';
|
const isValidSize = file.size <= 2 * 1024 * 1024;
|
||||||
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;
|
||||||
|
uploadLoading.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of validFiles) {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
uploadedImages.value.push(response.data);
|
||||||
|
}
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
uploadError.value = error.response?.data?.message || 'Gagal mengupload foto';
|
||||||
|
} finally {
|
||||||
|
uploadLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validFiles.length === 0) return;
|
|
||||||
|
|
||||||
uploadLoading.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const file of validFiles) {
|
|
||||||
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',
|
|
||||||
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadedImages.value.push(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInput.value) {
|
|
||||||
fileInput.value.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
uploadError.value = error.response?.data?.message || 'Gagal mengupload foto';
|
|
||||||
} finally {
|
|
||||||
uploadLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeImage = async (id) => {
|
const removeImage = async (id) => {
|
||||||
@ -404,21 +508,51 @@ const removeImage = async (id) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCameraModal = async () => {
|
||||||
|
showUploadMenu.value = false;
|
||||||
|
showCamera.value = true;
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
video.value.srcObject = stream;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal akses kamera:", err);
|
||||||
|
alert("Tidak bisa mengakses kamera, cek izin browser!");
|
||||||
|
closeCamera();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCamera = () => {
|
||||||
|
showCamera.value = false;
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const capturePhoto = () => {
|
||||||
|
const ctx = canvas.value.getContext("2d");
|
||||||
|
canvas.value.width = video.value.videoWidth;
|
||||||
|
canvas.value.height = video.value.videoHeight;
|
||||||
|
ctx.drawImage(video.value, 0, 0);
|
||||||
|
canvas.value.toBlob(async (blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
await uploadFiles([new File([blob], "camera_photo.png", { type: "image/png" })]);
|
||||||
|
closeCamera();
|
||||||
|
}, "image/png");
|
||||||
|
};
|
||||||
|
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await axios.put(
|
await axios.put(`/api/produk/${productId}`, form.value, {
|
||||||
`/api/produk/${productId}`,form.value,
|
headers: {
|
||||||
{
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
headers: {
|
},
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
});
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
router.push("/produk?message=Produk berhasil diperbarui");
|
router.push("/produk?message=Produk berhasil diperbarui");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessages.value = err.response?.data?.message || "Gagal menyimpan produk";
|
console.error("Submit error:", err);
|
||||||
console.error(err);
|
uploadError.value = err.response?.data?.message || "Gagal menyimpan produk";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -426,16 +560,30 @@ const submitForm = async () => {
|
|||||||
|
|
||||||
const closeItemModal = () => {
|
const closeItemModal = () => {
|
||||||
openItemModal.value = false;
|
openItemModal.value = false;
|
||||||
|
editedProduct.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = async () => {
|
||||||
router.push("/produk");
|
loading.value = true;
|
||||||
|
try{
|
||||||
|
console.log(localStorage.getItem("token"));
|
||||||
|
|
||||||
|
await axios.delete('/api/all/foto', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
router.push('/produk');
|
||||||
|
} catch (e){
|
||||||
|
console.error("Error image ", e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadKategori();
|
await loadKategori();
|
||||||
await loadProduk();
|
await loadProduk();
|
||||||
loadFoto();
|
await loadFoto();
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -35,13 +35,23 @@
|
|||||||
<div class="mb-3 flex flex-row w-full gap-3">
|
<div class="mb-3 flex flex-row w-full gap-3">
|
||||||
<div class="flex-1">
|
<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"
|
<InputField
|
||||||
placeholder="Masukkan harga per gram" @input="calculateHargaJual" />
|
v-model="hargaPerGramFormatted"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan harga per gram"
|
||||||
|
@input="formatHargaPerGramInput"
|
||||||
|
@keypress="onlyNumbers"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="block text-D mb-1">Harga Jual</label>
|
<label class="block text-D mb-1">Harga Jual</label>
|
||||||
<InputField v-model="form.harga_jual" type="number" step="0.01"
|
<InputField
|
||||||
placeholder="Masukkan harga jual" />
|
v-model="hargaJualFormatted"
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan harga jual"
|
||||||
|
@input="formatHargaJualInput"
|
||||||
|
@keypress="onlyNumbers"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -174,7 +184,12 @@ import CreateItemModal from "../components/CreateItemModal.vue";
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = ref({
|
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 category = ref([]);
|
||||||
const showUploadMenu = ref(false);
|
const showUploadMenu = ref(false);
|
||||||
@ -194,17 +209,84 @@ const video = ref(null);
|
|||||||
const canvas = ref(null);
|
const canvas = ref(null);
|
||||||
let stream = 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(() => {
|
const isFormValid = computed(() => {
|
||||||
return form.value.nama && form.value.id_kategori && form.value.berat > 0 &&
|
return (
|
||||||
form.value.kadar > 0 && form.value.harga_per_gram > 0 && form.value.harga_jual > 0 &&
|
form.value.nama &&
|
||||||
uploadedImages.value.length > 0;
|
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 calculateHargaJual = () => {
|
||||||
const berat = parseFloat(form.value.berat) || 0;
|
const berat = parseFloat(form.value.berat) || 0;
|
||||||
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
|
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
|
||||||
if (berat > 0 && hargaPerGram > 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")}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||||
});
|
});
|
||||||
if (response.data && Array.isArray(response.data)) {
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading categories:', error);
|
console.error('Error loading categories:', error);
|
||||||
@ -263,11 +345,17 @@ const uploadFiles = async (files) => {
|
|||||||
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
|
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const validFiles = files.filter(file => {
|
const validFiles = files.filter((file) => {
|
||||||
const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
|
const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
|
||||||
const isValidSize = file.size <= 2 * 1024 * 1024;
|
const isValidSize = file.size <= 2 * 1024 * 1024;
|
||||||
if (!isValidType) { uploadError.value = 'Format file harus JPG, JPEG, atau PNG'; return false; }
|
if (!isValidType) {
|
||||||
if (!isValidSize) { uploadError.value = 'Ukuran file maksimal 2MB'; return false; }
|
uploadError.value = 'Format file harus JPG, JPEG, atau PNG';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isValidSize) {
|
||||||
|
uploadError.value = 'Ukuran file maksimal 2MB';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (validFiles.length === 0) return;
|
if (validFiles.length === 0) return;
|
||||||
@ -277,7 +365,10 @@ const uploadFiles = async (files) => {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('foto', file);
|
formData.append('foto', file);
|
||||||
const response = await axios.post('/api/foto', formData, {
|
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);
|
uploadedImages.value.push(response.data);
|
||||||
}
|
}
|
||||||
@ -318,7 +409,7 @@ const openCameraModal = async () => {
|
|||||||
const closeCamera = () => {
|
const closeCamera = () => {
|
||||||
showCamera.value = false;
|
showCamera.value = false;
|
||||||
if (stream) {
|
if (stream) {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
stream = null;
|
stream = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -346,7 +437,16 @@ const submitForm = async (addItem) => {
|
|||||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||||
});
|
});
|
||||||
const createdProductData = response.data.data;
|
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 = [];
|
uploadedImages.value = [];
|
||||||
uploadError.value = '';
|
uploadError.value = '';
|
||||||
showUploadMenu.value = false;
|
showUploadMenu.value = false;
|
||||||
@ -368,9 +468,39 @@ 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; };
|
|
||||||
|
|
||||||
onMounted(() => { loadFoto(); loadKategori(); });
|
const back = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try{
|
||||||
|
console.log(localStorage.getItem("token"));
|
||||||
|
|
||||||
|
await axios.delete('/api/all/foto', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
router.push('/produk');
|
||||||
|
} catch (e){
|
||||||
|
console.error("Error image ", e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateItemModal = (product) => {
|
||||||
|
createdProduct.value = product;
|
||||||
|
openItemModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeItemModal = () => {
|
||||||
|
openItemModal.value = false;
|
||||||
|
createdProduct.value = null;
|
||||||
|
router.push('/produk');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFoto();
|
||||||
|
loadKategori();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-2 mx-auto min-h-[75vh]"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-2 mx-auto min-h-[75vh]"
|
||||||
>
|
>
|
||||||
<!-- Left Section - Form Kasir -->
|
|
||||||
<div class="lg:col-span-3">
|
<div class="lg:col-span-3">
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden h-auto lg:h-full"
|
class="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden h-auto lg:h-full"
|
||||||
@ -15,13 +14,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Section - Transaction List -->
|
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden lg:h-fit sticky top-4 max-h-[70vh] overflow-y-auto"
|
class="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden lg:h-fit sticky top-4 max-h-[70vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div class="p-3 sm:p-4 md:p-6">
|
<div class="p-3 sm:p-4 md:p-6">
|
||||||
<!-- Simplified Transaction List - Hari Ini Only -->
|
|
||||||
<KasirTransaksiList
|
<KasirTransaksiList
|
||||||
:transaksi="transaksi.data || []"
|
:transaksi="transaksi.data || []"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@ -34,7 +31,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ✅ POPUP KONFIRMASI -->
|
|
||||||
<ModalConfirm
|
<ModalConfirm
|
||||||
v-if="showConfirm"
|
v-if="showConfirm"
|
||||||
title="Konfirmasi"
|
title="Konfirmasi"
|
||||||
@ -52,7 +48,7 @@ import axios from "axios";
|
|||||||
import mainLayout from "../layouts/mainLayout.vue";
|
import mainLayout from "../layouts/mainLayout.vue";
|
||||||
import KasirForm from "../components/KasirForm.vue";
|
import KasirForm from "../components/KasirForm.vue";
|
||||||
import KasirTransaksiList from "../components/KasirTransaksiList.vue";
|
import KasirTransaksiList from "../components/KasirTransaksiList.vue";
|
||||||
import ModalConfirm from "../components/ModalConfirm.vue"; // ✅ Tambah ini
|
import ModalConfirm from "../components/ModalConfirm.vue";
|
||||||
|
|
||||||
const transaksi = ref({
|
const transaksi = ref({
|
||||||
data: [],
|
data: [],
|
||||||
@ -62,18 +58,15 @@ const loading = ref(true);
|
|||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
|
|
||||||
const showConfirm = ref(false); // ✅
|
const showConfirm = ref(false);
|
||||||
const confirmMessage = ref("Apakah kamu yakin?"); // ✅
|
const confirmMessage = ref("Apakah kamu yakin?");
|
||||||
let lastTransaksi = null; // untuk tau data transaksi terakhir
|
let lastTransaksi = null;
|
||||||
|
|
||||||
// ✅ Placeholder jika user tekan "Ya"
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
showConfirm.value = false;
|
showConfirm.value = false;
|
||||||
console.log("User konfirmasi, cetak struk di sini...", lastTransaksi);
|
console.log("User konfirmasi, cetak struk di sini...", lastTransaksi);
|
||||||
// TODO: jalankan fungsi cetakStruk(lastTransaksi)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch hanya transaksi hari ini
|
|
||||||
const fetchTransaksiHariIni = async (page = 1) => {
|
const fetchTransaksiHariIni = async (page = 1) => {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -106,47 +99,54 @@ const fetchTransaksiHariIni = async (page = 1) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle pagination
|
|
||||||
const handlePageChange = (page) => {
|
const handlePageChange = (page) => {
|
||||||
if (page >= 1 && page <= (transaksi.value.pagination?.last_page || 1)) {
|
if (page >= 1 && page <= (transaksi.value.pagination?.last_page || 1)) {
|
||||||
fetchTransaksiHariIni(page);
|
fetchTransaksiHariIni(page);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ Popup setelah transaksi tersimpan
|
|
||||||
const handleTransaksiSaved = async (newTransaksi) => {
|
const handleTransaksiSaved = async (newTransaksi) => {
|
||||||
|
// Format data transaksi baru
|
||||||
const formattedNewTransaksi = {
|
const formattedNewTransaksi = {
|
||||||
id: newTransaksi.id,
|
id: newTransaksi.id,
|
||||||
kode_transaksi: newTransaksi.kode_transaksi,
|
kode_transaksi: newTransaksi.kode_transaksi,
|
||||||
created_at: newTransaksi.created_at,
|
created_at: newTransaksi.created_at,
|
||||||
total_harga: newTransaksi.total_harga || 0,
|
total_harga: newTransaksi.total_harga || 0,
|
||||||
itemTransaksi: newTransaksi.itemTransaksi || [],
|
itemTransaksi: newTransaksi.itemTransaksi || newTransaksi.items || [],
|
||||||
pendapatan: newTransaksi.total_harga || 0,
|
pendapatan: newTransaksi.total_harga || 0,
|
||||||
total_items: newTransaksi.itemTransaksi?.length || 0,
|
total_items: (newTransaksi.itemTransaksi || newTransaksi.items || []).length,
|
||||||
tanggal: new Date(newTransaksi.created_at).toLocaleDateString('id-ID')
|
tanggal: new Date(newTransaksi.created_at).toLocaleDateString('id-ID'),
|
||||||
|
nama_pembeli: newTransaksi.nama_pembeli,
|
||||||
|
alamat: newTransaksi.alamat,
|
||||||
|
no_hp: newTransaksi.no_hp,
|
||||||
|
ongkos_bikin: newTransaksi.ongkos_bikin,
|
||||||
|
nama_sales: newTransaksi.nama_sales
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tambahkan ke awal list
|
||||||
transaksi.value.data.unshift(formattedNewTransaksi);
|
transaksi.value.data.unshift(formattedNewTransaksi);
|
||||||
lastTransaksi = formattedNewTransaksi; // ✅ Simpan untuk cetak
|
lastTransaksi = formattedNewTransaksi;
|
||||||
|
|
||||||
|
// Update pagination
|
||||||
if (transaksi.value.pagination) {
|
if (transaksi.value.pagination) {
|
||||||
transaksi.value.pagination.total += 1;
|
transaksi.value.pagination.total += 1;
|
||||||
|
// Hapus item terakhir jika melebihi limit
|
||||||
if (transaksi.value.data.length > limit) {
|
if (transaksi.value.data.length > limit) {
|
||||||
transaksi.value.data.pop();
|
transaksi.value.data.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmMessage.value = "Transaksi berhasil disimpan. Cetak struk sekarang?";
|
// Tidak perlu modal konfirmasi lagi karena sudah ada StrukView
|
||||||
showConfirm.value = true; // ✅ Munculkan popup
|
// confirmMessage.value = "Transaksi berhasil disimpan. Cetak struk sekarang?";
|
||||||
|
// showConfirm.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-refresh setiap 10 detik untuk update real-time
|
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
const startAutoRefresh = () => {
|
const startAutoRefresh = () => {
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
if (refreshInterval) clearInterval(refreshInterval);
|
||||||
refreshInterval = setInterval(() => {
|
refreshInterval = setInterval(() => {
|
||||||
fetchTransaksiHariIni(currentPage.value);
|
fetchTransaksiHariIni(currentPage.value);
|
||||||
}, 10000);
|
}, 30000); // Refresh setiap 30 detik (sebelumnya 10 detik)
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopAutoRefresh = () => {
|
const stopAutoRefresh = () => {
|
||||||
|
|||||||
@ -156,7 +156,7 @@ import { ref, onMounted, computed } from "vue";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import mainLayout from "../layouts/mainLayout.vue";
|
import mainLayout from "../layouts/mainLayout.vue";
|
||||||
import ProductCard from "../components/ProductCard.vue";
|
import ProductCard from "../components/ProductCard.vue";
|
||||||
import searchbar from "../components/searchbar.vue";
|
import searchbar from "../components/Searchbar.vue";
|
||||||
import CreateItemModal from "../components/CreateItemModal.vue";
|
import CreateItemModal from "../components/CreateItemModal.vue";
|
||||||
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
||||||
import InputSelect from "../components/InputSelect.vue";
|
import InputSelect from "../components/InputSelect.vue";
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<mainLayout>
|
<mainLayout>
|
||||||
<div class="p-6 flex flex-col sm:flex-row justify-between items-start gap-3">
|
<div class="p-6">
|
||||||
<p class="font-serif italic text-[25px] text-D">NAMPAN</p>
|
<p class="font-serif italic text-[25px] text-D">NAMPAN</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 justify-end w-full sm:w-auto">
|
<div class="w-full flex justify-end">
|
||||||
<Searchbar v-model:search="searchQuery" />
|
<div class="flex flex-col gap-3 justify-between w-full md:w-75 my-3">
|
||||||
<div class="flex w-full gap-2" v-if="isAdmin">
|
<Searchbar v-model:search="searchQuery" />
|
||||||
<button @click="openModal" class="px-4 py-2 sm:px-2 sm:py-1 hover:bg-B bg-C rounded-md shadow w-full">
|
<div class="flex w-full gap-2" v-if="isAdmin">
|
||||||
Tambah Nampan
|
<button @click="openModal" class="px-4 py-2 sm:px-2 sm:py-1 hover:bg-B bg-C rounded-md shadow w-full">
|
||||||
</button>
|
Tambah Nampan
|
||||||
<button @click="promptEmptyAllTrays"
|
</button>
|
||||||
class="px-4 py-2 sm:px-2 sm:py-1 bg-red-500 hover:bg-red-600 text-white rounded-md w-full">
|
<button @click="promptEmptyAllTrays"
|
||||||
Kosongkan Semua Nampan
|
class="px-4 py-2 sm:px-2 sm:py-1 bg-red-500 hover:bg-red-600 text-white rounded-md w-full">
|
||||||
</button>
|
Kosongkan Semua Nampan
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -60,7 +62,7 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import mainLayout from "../layouts/mainLayout.vue";
|
import mainLayout from "../layouts/mainLayout.vue";
|
||||||
import Searchbar from "../components/searchbar.vue";
|
import Searchbar from "../components/Searchbar.vue";
|
||||||
import TrayList from "../components/TrayList.vue";
|
import TrayList from "../components/TrayList.vue";
|
||||||
import InputField from "../components/InputField.vue";
|
import InputField from "../components/InputField.vue";
|
||||||
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
||||||
@ -99,9 +101,9 @@ const saveTray = async () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const headers = {
|
const headers = {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
};
|
};
|
||||||
if (editingTrayId.value) {
|
if (editingTrayId.value) {
|
||||||
await axios.put(`/api/nampan/${editingTrayId.value}`, { nama: trayName.value }, { headers });
|
await axios.put(`/api/nampan/${editingTrayId.value}`, { nama: trayName.value }, { headers });
|
||||||
@ -119,7 +121,7 @@ const saveTray = async () => {
|
|||||||
const errors = error.response?.data?.errors?.nama || [];
|
const errors = error.response?.data?.errors?.nama || [];
|
||||||
console.log(errors);
|
console.log(errors);
|
||||||
errorCreate.value = errors[0] || 'Gagal menyimpan nampan.';
|
errorCreate.value = errors[0] || 'Gagal menyimpan nampan.';
|
||||||
|
|
||||||
clearTimeout(timer.value);
|
clearTimeout(timer.value);
|
||||||
timer.value = setTimeout(() => { errorCreate.value = ""; }, 3000);
|
timer.value = setTimeout(() => { errorCreate.value = ""; }, 3000);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ Route::prefix('api')->group(function () {
|
|||||||
Route::post('foto', [FotoSementaraController::class, 'upload']);
|
Route::post('foto', [FotoSementaraController::class, 'upload']);
|
||||||
Route::delete('foto/{id}', [FotoSementaraController::class, 'hapus']);
|
Route::delete('foto/{id}', [FotoSementaraController::class, 'hapus']);
|
||||||
Route::get('produk/edit/{id}', [ProdukController::class, 'edit']);
|
Route::get('produk/edit/{id}', [ProdukController::class, 'edit']);
|
||||||
Route::delete('foto/all', [FotoSementaraController::class, 'reset']);
|
Route::delete('all/foto', [FotoSementaraController::class, 'reset']);
|
||||||
|
|
||||||
// Laporan
|
// Laporan
|
||||||
Route::prefix('laporan')->group(function () {
|
Route::prefix('laporan')->group(function () {
|
||||||
|
|||||||
1485
storage/toko_emas.sql
Normal file
1485
storage/toko_emas.sql
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user