Compare commits
5 Commits
d0bdd4bb63
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eb4ed3c9a | |||
| 3dad273a4c | |||
| 4c5b62dd70 | |||
| 3a52d29f8c | |||
| 2df17526c9 |
@ -14,6 +14,115 @@ Dokumen ini menyimpan konteks kerja terbaru setelah rangkaian patch mobile, logi
|
|||||||
|
|
||||||
## Ringkasan Perubahan Terbaru
|
## Ringkasan Perubahan Terbaru
|
||||||
|
|
||||||
|
### 0. Update Pembelian, Penerimaan, Label Lot, dan Navigasi - 2026-05-29
|
||||||
|
|
||||||
|
- Branch `main` sudah sinkron dengan `origin/main`.
|
||||||
|
- Commit terakhir saat handoff ini diperbarui:
|
||||||
|
`4c5b62d Prefer specific active navigation links`
|
||||||
|
- Commit terkait patch terbaru:
|
||||||
|
- `44146a4` `Split purchase submit from receipt finalization`
|
||||||
|
- `57896d3` `Add receipt menu access`
|
||||||
|
- `1217529` `Complete receipt translations`
|
||||||
|
- `d0bdd4b` `Refine purchase receipt and lot label flow`
|
||||||
|
- `2df1752` `Render receipt lot labels with QR and barcode`
|
||||||
|
- `3a52d29` `Keep receipt action labels on one line`
|
||||||
|
- `4c5b62d` `Prefer specific active navigation links`
|
||||||
|
|
||||||
|
#### Pembelian Reguler
|
||||||
|
|
||||||
|
- Halaman `Pembelian Reguler` tidak lagi meminta input `Gudang` dan `Lokasi`.
|
||||||
|
- Tombol submit lama yang langsung membuat receipt/lot sudah tidak dipakai dari halaman pembelian.
|
||||||
|
- Pembelian sekarang disimpan sebagai draft/data purchase, lalu proses penerimaan dilakukan dari menu:
|
||||||
|
`Pembelian > Penerimaan`.
|
||||||
|
- Payload purchase tetap kompatibel dengan API lama, tetapi `warehouse_id` dan `warehouse_location_id` dikirim kosong/null.
|
||||||
|
- File utama:
|
||||||
|
- [src/features/purchases/components/purchases-client.tsx](/home/wira/work/codex/AbelBirdNest-Stock/src/features/purchases/components/purchases-client.tsx)
|
||||||
|
|
||||||
|
#### Penerimaan dan Generate Lot
|
||||||
|
|
||||||
|
- Menu `Pembelian > Penerimaan` menjadi tempat utama untuk:
|
||||||
|
- memilih pembelian yang belum punya receipt
|
||||||
|
- mengisi qty diterima/valid/ditolak
|
||||||
|
- memilih gudang dan lokasi
|
||||||
|
- membuat receipt
|
||||||
|
- generate lot dari receipt
|
||||||
|
- API receipt mencegah receipt/lot dobel untuk pembelian yang sama.
|
||||||
|
- Saat `Buat lot`, status purchase ikut menjadi `SUBMITTED`.
|
||||||
|
- Generate lot dari receipt mempertahankan logic realization/allocation terbaru:
|
||||||
|
- membuat `inventory_lots`
|
||||||
|
- membuat `lot_purchase_allocations`
|
||||||
|
- membuat `purchase_realization_entries`
|
||||||
|
- recalculation summary realization
|
||||||
|
- Kode lot dari satu receipt dibuat berurutan dalam satu transaksi agar tidak bentrok untuk receipt multi-line.
|
||||||
|
- File utama:
|
||||||
|
- [src/features/receipts/components/receipts-client.tsx](/home/wira/work/codex/AbelBirdNest-Stock/src/features/receipts/components/receipts-client.tsx)
|
||||||
|
- [src/app/api/v1/receipts/route.ts](/home/wira/work/codex/AbelBirdNest-Stock/src/app/api/v1/receipts/route.ts)
|
||||||
|
- [src/app/api/v1/receipts/[id]/generate-lots/route.ts](/home/wira/work/codex/AbelBirdNest-Stock/src/app/api/v1/receipts/[id]/generate-lots/route.ts)
|
||||||
|
- [src/features/receipts/lib/serialize-receipt.ts](/home/wira/work/codex/AbelBirdNest-Stock/src/features/receipts/lib/serialize-receipt.ts)
|
||||||
|
- [src/types/receipt.ts](/home/wira/work/codex/AbelBirdNest-Stock/src/types/receipt.ts)
|
||||||
|
|
||||||
|
#### Cetak Receipt dan Label Lot dari Penerimaan
|
||||||
|
|
||||||
|
- Daftar/detail penerimaan sekarang punya tombol:
|
||||||
|
- `Receipt` / `Cetak receipt`
|
||||||
|
- `Label lot` / `Cetak label lot`
|
||||||
|
- Cetak label lot dari penerimaan sudah memakai QR code dan barcode sungguhan, dengan library yang sama seperti halaman detail lot:
|
||||||
|
- `qrcode`
|
||||||
|
- `jsbarcode`
|
||||||
|
- Tombol aksi daftar penerimaan dipaksa satu baris agar label `Label lot` tidak wrap.
|
||||||
|
- Label lot dari receipt mengambil nilai scan dari:
|
||||||
|
- `qr_code_value`
|
||||||
|
- `barcode_value`
|
||||||
|
- fallback ke `lot_code`
|
||||||
|
|
||||||
|
#### Navigasi Aktif
|
||||||
|
|
||||||
|
- Sidebar dan mobile nav sebelumnya bisa menandai `Pembelian Reguler` aktif saat user berada di:
|
||||||
|
`/purchases/office-buyout`
|
||||||
|
- Active state sekarang memilih href paling spesifik/paling panjang ketika beberapa route cocok.
|
||||||
|
- File utama:
|
||||||
|
- [src/components/layout/sidebar.tsx](/home/wira/work/codex/AbelBirdNest-Stock/src/components/layout/sidebar.tsx)
|
||||||
|
- [src/components/layout/mobile-nav.tsx](/home/wira/work/codex/AbelBirdNest-Stock/src/components/layout/mobile-nav.tsx)
|
||||||
|
|
||||||
|
#### Script Seed Lokal
|
||||||
|
|
||||||
|
- File `scripts/seed-local-superadmin.mjs` sudah ikut masuk git sesuai kebutuhan dev/test.
|
||||||
|
- Script ini membuat role dan user `SYSTEM_ADMIN` lokal.
|
||||||
|
- Catatan keamanan: script berisi password hardcoded `password`, jadi pakai hanya untuk environment lokal/dev, bukan production.
|
||||||
|
|
||||||
|
#### Verifikasi Patch Terbaru
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` lolos setelah patch:
|
||||||
|
- split pembelian/penerimaan
|
||||||
|
- cetak receipt
|
||||||
|
- cetak label lot QR/barcode
|
||||||
|
- active navigation fix
|
||||||
|
|
||||||
|
#### Update Server Dev
|
||||||
|
|
||||||
|
- Untuk server dev boleh compile langsung di server via npm.
|
||||||
|
- Command umum:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/ke/AbelBirdNest-Stock
|
||||||
|
git pull origin main
|
||||||
|
npm ci
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run build
|
||||||
|
sudo rc-service abelbirdnest-web restart
|
||||||
|
sudo rc-service abelbirdnest-web status
|
||||||
|
curl -I http://127.0.0.1:3007/login
|
||||||
|
```
|
||||||
|
|
||||||
|
- Jika server dev memakai systemd:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart abelbirdnest-web
|
||||||
|
sudo systemctl status abelbirdnest-web --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tidak ada migration Prisma baru dari patch ini.
|
||||||
|
|
||||||
### 1. Flow Mobile Purchase
|
### 1. Flow Mobile Purchase
|
||||||
|
|
||||||
- Menu `Receipt` di mobile sudah dikeluarkan dari bootstrap mobile.
|
- Menu `Receipt` di mobile sudah dikeluarkan dari bootstrap mobile.
|
||||||
|
|||||||
529
docs/deploy-debian13-low-ram.md
Normal file
529
docs/deploy-debian13-low-ram.md
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
# Deploy Debian 13 Production Low RAM
|
||||||
|
|
||||||
|
Panduan ini mencatat langkah deploy production Abel Birdnest pada server Debian 13 kosong dengan RAM kecil. Jalur ini sudah terbukti bisa dipakai dengan mengaktifkan swap sebelum `next build`.
|
||||||
|
|
||||||
|
Target:
|
||||||
|
|
||||||
|
- Domain: `abelbirdnest.id`
|
||||||
|
- SSH port: `3853`
|
||||||
|
- App user: `abelbirdnest`
|
||||||
|
- App path: `/var/www/abelbirdnest-web`
|
||||||
|
- App port: `3007`
|
||||||
|
- Database: PostgreSQL lokal
|
||||||
|
- Reverse proxy: Nginx
|
||||||
|
- SSL: Let's Encrypt
|
||||||
|
|
||||||
|
## 1. Login Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 3853 root@IP_SERVER
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika memakai user sudo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 3853 namauser@IP_SERVER
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. DNS
|
||||||
|
|
||||||
|
Pastikan DNS mengarah ke IP server:
|
||||||
|
|
||||||
|
```text
|
||||||
|
abelbirdnest.id A IP_SERVER
|
||||||
|
www.abelbirdnest.id A IP_SERVER
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek dari komputer lokal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short abelbirdnest.id
|
||||||
|
dig +short www.abelbirdnest.id
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Install Paket Dasar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt full-upgrade -y
|
||||||
|
sudo apt install -y git curl ca-certificates build-essential nodejs npm postgresql postgresql-contrib nginx certbot python3-certbot-nginx ufw
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek versi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
psql --version
|
||||||
|
nginx -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Firewall
|
||||||
|
|
||||||
|
Karena SSH memakai port `3853`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 3853/tcp
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw status
|
||||||
|
```
|
||||||
|
|
||||||
|
Jangan tutup akses SSH lama sebelum login lewat port `3853` sudah terbukti berhasil.
|
||||||
|
|
||||||
|
## 5. Aktifkan Swap untuk Server RAM Rendah
|
||||||
|
|
||||||
|
Jika server hanya punya RAM kecil, `npm run build` bisa mati dengan pesan seperti:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Next.js build worker exited with code: null and signal: SIGKILL
|
||||||
|
```
|
||||||
|
|
||||||
|
Itu biasanya karena proses build kehabisan memori. Aktifkan swap sebelum build.
|
||||||
|
|
||||||
|
Swap 2 GB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo fallocate -l 2G /swapfile
|
||||||
|
sudo chmod 600 /swapfile
|
||||||
|
sudo mkswap /swapfile
|
||||||
|
sudo swapon /swapfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Buat permanen setelah reboot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
free -h
|
||||||
|
swapon --show
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika 2 GB masih kurang, ganti menjadi 4 GB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo swapoff /swapfile
|
||||||
|
sudo rm /swapfile
|
||||||
|
sudo fallocate -l 4G /swapfile
|
||||||
|
sudo chmod 600 /swapfile
|
||||||
|
sudo mkswap /swapfile
|
||||||
|
sudo swapon /swapfile
|
||||||
|
free -h
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika `/etc/fstab` sudah berisi baris `/swapfile`, tidak perlu menambah baris lagi.
|
||||||
|
|
||||||
|
## 6. Buat User Aplikasi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd -r -m -d /var/www/abelbirdnest-web -s /bin/bash abelbirdnest
|
||||||
|
sudo mkdir -p /var/www/abelbirdnest-web
|
||||||
|
sudo chown -R abelbirdnest:abelbirdnest /var/www/abelbirdnest-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Clone Repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u abelbirdnest -H bash
|
||||||
|
cd /var/www/abelbirdnest-web
|
||||||
|
git clone https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git .
|
||||||
|
git checkout main
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika Git meminta login atau token, gunakan credential Git internal yang berlaku.
|
||||||
|
|
||||||
|
## 8. Setup Database PostgreSQL
|
||||||
|
|
||||||
|
Generate password database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Masuk PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql
|
||||||
|
```
|
||||||
|
|
||||||
|
Buat user dan database. Ganti `GANTI_PASSWORD_DB_KUAT` dengan password yang dibuat tadi.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER abelbirdnest_app WITH PASSWORD 'GANTI_PASSWORD_DB_KUAT';
|
||||||
|
CREATE DATABASE abelbirdnest_prod OWNER abelbirdnest_app;
|
||||||
|
REVOKE ALL ON DATABASE abelbirdnest_prod FROM PUBLIC;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE abelbirdnest_prod TO abelbirdnest_app;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
Tes koneksi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "postgresql://abelbirdnest_app:GANTI_PASSWORD_DB_KUAT@127.0.0.1:5432/abelbirdnest_prod?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
Keluar dari prompt `psql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Buat Environment Production
|
||||||
|
|
||||||
|
Generate secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
Buat file env:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u abelbirdnest nano /var/www/abelbirdnest-web/.env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
Isi:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3007
|
||||||
|
APP_URL=https://abelbirdnest.id
|
||||||
|
DATABASE_URL="postgresql://abelbirdnest_app:GANTI_PASSWORD_DB_KUAT@127.0.0.1:5432/abelbirdnest_prod?schema=public"
|
||||||
|
AUTH_SECRET="GANTI_DENGAN_HASIL_OPENSSL"
|
||||||
|
AUTH_BOOTSTRAP=false
|
||||||
|
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_SECURE=true
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM="Abel Birdnest <no-reply@abelbirdnest.id>"
|
||||||
|
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
Kunci permission:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown abelbirdnest:abelbirdnest /var/www/abelbirdnest-web/.env.production
|
||||||
|
sudo chmod 600 /var/www/abelbirdnest-web/.env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Install Dependency, Migration, dan Seed Master
|
||||||
|
|
||||||
|
Prisma CLI perlu `DATABASE_URL`. Karena `.env.production` tidak selalu otomatis dibaca oleh perintah Prisma, load env sebelum migration dan seed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u abelbirdnest -H bash
|
||||||
|
cd /var/www/abelbirdnest-web
|
||||||
|
set -a
|
||||||
|
source .env.production
|
||||||
|
set +a
|
||||||
|
npm install
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run prisma:migrate:deploy
|
||||||
|
npm run seed:master
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run seed:master` mengisi:
|
||||||
|
|
||||||
|
- currency
|
||||||
|
- grade
|
||||||
|
- bank
|
||||||
|
|
||||||
|
Jika muncul error:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Environment variable not found: DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Artinya env belum ter-load. Ulangi dari:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a
|
||||||
|
source .env.production
|
||||||
|
set +a
|
||||||
|
```
|
||||||
|
|
||||||
|
Lalu cek:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "$DATABASE_URL" | sed 's/:.*@/:***@/'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Seed Admin Awal
|
||||||
|
|
||||||
|
Jalankan script superadmin setelah migration selesai:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u abelbirdnest -H bash
|
||||||
|
cd /var/www/abelbirdnest-web
|
||||||
|
set -a
|
||||||
|
source .env.production
|
||||||
|
set +a
|
||||||
|
node scripts/seed-local-superadmin.mjs
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
User yang dibuat:
|
||||||
|
|
||||||
|
```text
|
||||||
|
email: wirabasalamah@gmail.com
|
||||||
|
username: wirabasalamah
|
||||||
|
password: password
|
||||||
|
role: SYSTEM_ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
Setelah login pertama, segera ganti password.
|
||||||
|
|
||||||
|
## 12. Build Production pada RAM Rendah
|
||||||
|
|
||||||
|
Pastikan swap aktif:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
free -h
|
||||||
|
swapon --show
|
||||||
|
```
|
||||||
|
|
||||||
|
Build dengan limit memory Node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u abelbirdnest -H bash
|
||||||
|
cd /var/www/abelbirdnest-web
|
||||||
|
set -a
|
||||||
|
source .env.production
|
||||||
|
set +a
|
||||||
|
NODE_OPTIONS=--max-old-space-size=1024 npm run build
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika masih mati dengan `SIGKILL`, cek OOM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dmesg -T | tail -80
|
||||||
|
```
|
||||||
|
|
||||||
|
Cari baris `Out of memory`, `Killed process`, atau `node`. Jika ada, naikkan swap menjadi 4 GB lalu ulang build.
|
||||||
|
|
||||||
|
## 13. Setup systemd
|
||||||
|
|
||||||
|
Buat service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/abelbirdnest-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Isi:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=AbelBirdnest Stock Next.js
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/var/www/abelbirdnest-web
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=3007
|
||||||
|
Environment=NODE_OPTIONS=--max-old-space-size=512
|
||||||
|
EnvironmentFile=/var/www/abelbirdnest-web/.env.production
|
||||||
|
ExecStart=/usr/bin/npm run start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=abelbirdnest
|
||||||
|
Group=abelbirdnest
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Start service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable abelbirdnest-web
|
||||||
|
sudo systemctl start abelbirdnest-web
|
||||||
|
sudo systemctl status abelbirdnest-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek lokal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:3007/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Setup Nginx HTTP
|
||||||
|
|
||||||
|
Buat config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/nginx/sites-available/abelbirdnest.id.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Isi:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name abelbirdnest.id www.abelbirdnest.id;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3007;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto http;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/abelbirdnest.id.conf /etc/nginx/sites-enabled/abelbirdnest.id.conf
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://abelbirdnest.id/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Pasang SSL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d abelbirdnest.id -d www.abelbirdnest.id
|
||||||
|
```
|
||||||
|
|
||||||
|
Pilih redirect HTTP ke HTTPS jika ditanya.
|
||||||
|
|
||||||
|
Tes renew:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek HTTPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://abelbirdnest.id/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16. Login Pertama
|
||||||
|
|
||||||
|
Buka:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://abelbirdnest.id/login
|
||||||
|
```
|
||||||
|
|
||||||
|
Login:
|
||||||
|
|
||||||
|
```text
|
||||||
|
username/email: wirabasalamah atau wirabasalamah@gmail.com
|
||||||
|
password: password
|
||||||
|
```
|
||||||
|
|
||||||
|
Setelah login:
|
||||||
|
|
||||||
|
1. Ganti password admin seed.
|
||||||
|
2. Buat user production final.
|
||||||
|
3. Cek master data: currency, grade, bank.
|
||||||
|
4. Tes alur penting: purchase, receipt, lot, sales.
|
||||||
|
|
||||||
|
## 17. Update Deployment Berikutnya
|
||||||
|
|
||||||
|
Untuk server RAM rendah, tetap load env dan pakai swap sebelum build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u abelbirdnest -H bash
|
||||||
|
cd /var/www/abelbirdnest-web
|
||||||
|
git pull origin main
|
||||||
|
set -a
|
||||||
|
source .env.production
|
||||||
|
set +a
|
||||||
|
npm install
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run prisma:migrate:deploy
|
||||||
|
NODE_OPTIONS=--max-old-space-size=1024 npm run build
|
||||||
|
exit
|
||||||
|
|
||||||
|
sudo systemctl restart abelbirdnest-web
|
||||||
|
curl https://abelbirdnest.id/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 18. Log dan Troubleshooting
|
||||||
|
|
||||||
|
Lihat log service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u abelbirdnest-web -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status abelbirdnest-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart abelbirdnest-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Cek OOM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dmesg -T | tail -80
|
||||||
|
```
|
||||||
|
|
||||||
|
## 19. Backup Database
|
||||||
|
|
||||||
|
Backup manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres pg_dump abelbirdnest_prod > /root/abelbirdnest_prod_$(date +%F_%H%M).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql abelbirdnest_prod < /root/NAMA_FILE_BACKUP.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 20. Checklist Go-Live
|
||||||
|
|
||||||
|
- DNS `abelbirdnest.id` benar.
|
||||||
|
- DNS `www.abelbirdnest.id` benar.
|
||||||
|
- SSH port `3853` bisa login.
|
||||||
|
- Firewall buka `3853`, `80`, dan `443`.
|
||||||
|
- Swap aktif dan permanen.
|
||||||
|
- PostgreSQL jalan.
|
||||||
|
- `.env.production` ada dan permission `600`.
|
||||||
|
- Migration sukses.
|
||||||
|
- Seed currency, grade, dan bank sukses.
|
||||||
|
- Seed admin sukses.
|
||||||
|
- Build sukses dengan `NODE_OPTIONS=--max-old-space-size=1024`.
|
||||||
|
- systemd enabled dan running.
|
||||||
|
- Nginx proxy jalan.
|
||||||
|
- SSL aktif.
|
||||||
|
- `https://abelbirdnest.id/api/v1/health` ok.
|
||||||
|
- Admin bisa login.
|
||||||
|
- Password default sudah diganti.
|
||||||
|
- Backup database siap.
|
||||||
@ -9,6 +9,12 @@ Dokumen ini menyiapkan deploy production untuk:
|
|||||||
- source code dari git `https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git`
|
- source code dari git `https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git`
|
||||||
- user service khusus `abelbirdnest`
|
- user service khusus `abelbirdnest`
|
||||||
|
|
||||||
|
Untuk server Debian 13 kosong dengan RAM rendah, SSH port `3853`, swap, seed admin, dan langkah lengkap yang sudah terbukti berhasil, lihat juga:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docs/deploy-debian13-low-ram.md
|
||||||
|
```
|
||||||
|
|
||||||
## 1. Persiapan Server
|
## 1. Persiapan Server
|
||||||
|
|
||||||
Siapkan:
|
Siapkan:
|
||||||
|
|||||||
@ -19,15 +19,19 @@ function matchesPath(currentPath: string, href: string) {
|
|||||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findActiveHref(currentPath: string, items: Array<{ href: string }>) {
|
||||||
|
return items
|
||||||
|
.filter((item) => matchesPath(currentPath, item.href))
|
||||||
|
.sort((a, b) => b.href.length - a.href.length)[0]?.href;
|
||||||
|
}
|
||||||
|
|
||||||
export function MobileNav({ pathname, userRole }: MobileNavProps) {
|
export function MobileNav({ pathname, userRole }: MobileNavProps) {
|
||||||
const { dict } = useLocale();
|
const { dict } = useLocale();
|
||||||
const navigation = useMemo(() => getNavigationForRole(userRole as AppRole), [userRole]);
|
const navigation = useMemo(() => getNavigationForRole(userRole as AppRole), [userRole]);
|
||||||
const buildOpenGroups = () => {
|
const buildOpenGroups = () => {
|
||||||
const grouped = navigation.filter(isNavGroup);
|
const grouped = navigation.filter(isNavGroup);
|
||||||
return grouped.reduce<Record<string, boolean>>((acc, item) => {
|
return grouped.reduce<Record<string, boolean>>((acc, item) => {
|
||||||
const isGroupActive = item.children.some(
|
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
|
||||||
(child) => matchesPath(pathname, child.href)
|
|
||||||
);
|
|
||||||
acc[item.key] = isGroupActive;
|
acc[item.key] = isGroupActive;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@ -39,9 +43,7 @@ export function MobileNav({ pathname, userRole }: MobileNavProps) {
|
|||||||
setOpenGroups((current) => {
|
setOpenGroups((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
navigation.filter(isNavGroup).forEach((item) => {
|
navigation.filter(isNavGroup).forEach((item) => {
|
||||||
const isGroupActive = item.children.some(
|
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
|
||||||
(child) => matchesPath(pathname, child.href)
|
|
||||||
);
|
|
||||||
if (isGroupActive) {
|
if (isGroupActive) {
|
||||||
next[item.key] = true;
|
next[item.key] = true;
|
||||||
}
|
}
|
||||||
@ -55,9 +57,8 @@ export function MobileNav({ pathname, userRole }: MobileNavProps) {
|
|||||||
<div className="space-y-2 pb-1">
|
<div className="space-y-2 pb-1">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
if (isNavGroup(item)) {
|
if (isNavGroup(item)) {
|
||||||
const isGroupActive = item.children.some(
|
const activeChildHref = findActiveHref(pathname, item.children);
|
||||||
(child) => matchesPath(pathname, child.href)
|
const isGroupActive = Boolean(activeChildHref);
|
||||||
);
|
|
||||||
const isOpen = openGroups[item.key] ?? isGroupActive;
|
const isOpen = openGroups[item.key] ?? isGroupActive;
|
||||||
return (
|
return (
|
||||||
<div key={item.key} className="rounded border border-line/70 bg-slate-50">
|
<div key={item.key} className="rounded border border-line/70 bg-slate-50">
|
||||||
@ -83,8 +84,7 @@ export function MobileNav({ pathname, userRole }: MobileNavProps) {
|
|||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div className="grid gap-2 border-t border-line/70 bg-white p-2">
|
<div className="grid gap-2 border-t border-line/70 bg-white p-2">
|
||||||
{item.children.map((child) => {
|
{item.children.map((child) => {
|
||||||
const isActive =
|
const isActive = activeChildHref === child.href;
|
||||||
matchesPath(pathname, child.href);
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={child.href}
|
key={child.href}
|
||||||
|
|||||||
@ -20,15 +20,19 @@ function matchesPath(currentPath: string, href: string) {
|
|||||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findActiveHref(currentPath: string, items: Array<{ href: string }>) {
|
||||||
|
return items
|
||||||
|
.filter((item) => matchesPath(currentPath, item.href))
|
||||||
|
.sort((a, b) => b.href.length - a.href.length)[0]?.href;
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar({ pathname, user }: SidebarProps) {
|
export function Sidebar({ pathname, user }: SidebarProps) {
|
||||||
const { dict } = useLocale();
|
const { dict } = useLocale();
|
||||||
const navigation = useMemo(() => getNavigationForRole(user.role as never), [user.role]);
|
const navigation = useMemo(() => getNavigationForRole(user.role as never), [user.role]);
|
||||||
const buildOpenGroups = () => {
|
const buildOpenGroups = () => {
|
||||||
const grouped = navigation.filter(isNavGroup);
|
const grouped = navigation.filter(isNavGroup);
|
||||||
return grouped.reduce<Record<string, boolean>>((acc, item) => {
|
return grouped.reduce<Record<string, boolean>>((acc, item) => {
|
||||||
const isGroupActive = item.children.some(
|
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
|
||||||
(child) => matchesPath(pathname, child.href)
|
|
||||||
);
|
|
||||||
acc[item.key] = isGroupActive;
|
acc[item.key] = isGroupActive;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@ -40,9 +44,7 @@ export function Sidebar({ pathname, user }: SidebarProps) {
|
|||||||
setOpenGroups((current) => {
|
setOpenGroups((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
navigation.filter(isNavGroup).forEach((item) => {
|
navigation.filter(isNavGroup).forEach((item) => {
|
||||||
const isGroupActive = item.children.some(
|
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
|
||||||
(child) => matchesPath(pathname, child.href)
|
|
||||||
);
|
|
||||||
if (isGroupActive) {
|
if (isGroupActive) {
|
||||||
next[item.key] = true;
|
next[item.key] = true;
|
||||||
}
|
}
|
||||||
@ -70,9 +72,8 @@ export function Sidebar({ pathname, user }: SidebarProps) {
|
|||||||
<nav className="space-y-1 pr-1">
|
<nav className="space-y-1 pr-1">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
if (isNavGroup(item)) {
|
if (isNavGroup(item)) {
|
||||||
const isGroupActive = item.children.some(
|
const activeChildHref = findActiveHref(pathname, item.children);
|
||||||
(child) => matchesPath(pathname, child.href)
|
const isGroupActive = Boolean(activeChildHref);
|
||||||
);
|
|
||||||
const isOpen = openGroups[item.key] ?? isGroupActive;
|
const isOpen = openGroups[item.key] ?? isGroupActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -108,8 +109,7 @@ export function Sidebar({ pathname, user }: SidebarProps) {
|
|||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div className="mt-1 space-y-1 pl-3">
|
<div className="mt-1 space-y-1 pl-3">
|
||||||
{item.children.map((child) => {
|
{item.children.map((child) => {
|
||||||
const isActive =
|
const isActive = activeChildHref === child.href;
|
||||||
matchesPath(pathname, child.href);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { Printer, Tags } from "lucide-react";
|
import { Printer, Tags } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import JsBarcode from "jsbarcode";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
import { composeGradeLabel } from "@/lib/grade-display";
|
import { composeGradeLabel } from "@/lib/grade-display";
|
||||||
@ -264,7 +266,7 @@ export function ReceiptsClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printLotLabels(detail: ReceiptDetail) {
|
async function printLotLabels(detail: ReceiptDetail) {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (detail.generated_lots.length === 0) {
|
if (detail.generated_lots.length === 0) {
|
||||||
setError(locale === "id" ? "Belum ada lot yang bisa dicetak. Buat lot terlebih dahulu." : "No lot labels to print yet. Generate lots first.");
|
setError(locale === "id" ? "Belum ada lot yang bisa dicetak. Buat lot terlebih dahulu." : "No lot labels to print yet. Generate lots first.");
|
||||||
@ -279,7 +281,29 @@ export function ReceiptsClient() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
printWindow.document.open();
|
printWindow.document.open();
|
||||||
printWindow.document.write(buildLotLabelsPrintHtml(detail, locale));
|
printWindow.document.write(`<!doctype html><html><head><title>Menyiapkan Label</title></head><body style="font-family: Arial, sans-serif; padding: 24px;">${locale === "id" ? "Menyiapkan label lot..." : "Preparing lot labels..."}</body></html>`);
|
||||||
|
printWindow.document.close();
|
||||||
|
|
||||||
|
const printableLots = await Promise.all(
|
||||||
|
detail.generated_lots.map(async (lot) => {
|
||||||
|
const qrValue = lot.qr_code_value || lot.barcode_value || lot.lot_code;
|
||||||
|
const barcodeValue = lot.barcode_value || lot.qr_code_value || lot.lot_code;
|
||||||
|
return {
|
||||||
|
lot,
|
||||||
|
qrValue,
|
||||||
|
barcodeValue,
|
||||||
|
qrDataUrl: await QRCode.toDataURL(qrValue, {
|
||||||
|
width: 160,
|
||||||
|
margin: 1,
|
||||||
|
errorCorrectionLevel: "M"
|
||||||
|
}),
|
||||||
|
barcodeSvg: buildBarcodeSvg(barcodeValue)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write(buildLotLabelsPrintHtml(detail, locale, printableLots));
|
||||||
printWindow.document.close();
|
printWindow.document.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!printWindow.closed) {
|
if (!printWindow.closed) {
|
||||||
@ -407,13 +431,13 @@ export function ReceiptsClient() {
|
|||||||
<span className={item.status === "FINALIZED" ? "ops-chip-active" : "ops-chip-muted"}>{item.status}</span>
|
<span className={item.status === "FINALIZED" ? "ops-chip-active" : "ops-chip-muted"}>{item.status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-nowrap gap-2">
|
||||||
<button type="button" onClick={() => void openReceipt(item.id)} className="ops-btn-secondary">{dict.common.detail}</button>
|
<button type="button" onClick={() => void openReceipt(item.id)} className="ops-btn-secondary whitespace-nowrap">{dict.common.detail}</button>
|
||||||
{item.status !== "FINALIZED" ? <button type="button" onClick={() => void generateLots(item.id)} className="ops-btn-primary">{dict.receipts.generateLots}</button> : null}
|
{item.status !== "FINALIZED" ? <button type="button" onClick={() => void generateLots(item.id)} className="ops-btn-primary whitespace-nowrap">{dict.receipts.generateLots}</button> : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void printReceiptById(item.id)}
|
onClick={() => void printReceiptById(item.id)}
|
||||||
className="ops-btn-secondary"
|
className="ops-btn-secondary whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Printer className="h-4 w-4" />
|
<Printer className="h-4 w-4" />
|
||||||
{locale === "id" ? "Receipt" : "Receipt"}
|
{locale === "id" ? "Receipt" : "Receipt"}
|
||||||
@ -422,7 +446,7 @@ export function ReceiptsClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void printLotLabelsById(item.id)}
|
onClick={() => void printLotLabelsById(item.id)}
|
||||||
className="ops-btn-secondary"
|
className="ops-btn-secondary whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Tags className="h-4 w-4" />
|
<Tags className="h-4 w-4" />
|
||||||
{locale === "id" ? "Label lot" : "Lot labels"}
|
{locale === "id" ? "Label lot" : "Lot labels"}
|
||||||
@ -452,7 +476,7 @@ export function ReceiptsClient() {
|
|||||||
{locale === "id" ? "Cetak receipt" : "Print receipt"}
|
{locale === "id" ? "Cetak receipt" : "Print receipt"}
|
||||||
</button>
|
</button>
|
||||||
{selectedReceipt.generated_lots.length > 0 ? (
|
{selectedReceipt.generated_lots.length > 0 ? (
|
||||||
<button type="button" onClick={() => printLotLabels(selectedReceipt)} className="ops-btn-secondary">
|
<button type="button" onClick={() => void printLotLabels(selectedReceipt)} className="ops-btn-secondary">
|
||||||
<Tags className="h-4 w-4" />
|
<Tags className="h-4 w-4" />
|
||||||
{locale === "id" ? "Cetak label lot" : "Print lot labels"}
|
{locale === "id" ? "Cetak label lot" : "Print lot labels"}
|
||||||
</button>
|
</button>
|
||||||
@ -497,6 +521,23 @@ function escapeHtml(value: string) {
|
|||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBarcodeSvg(value: string) {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
JsBarcode(svg, value, {
|
||||||
|
format: "CODE128",
|
||||||
|
displayValue: false,
|
||||||
|
margin: 0,
|
||||||
|
width: 1.5,
|
||||||
|
height: 44
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.setAttribute("width", "100%");
|
||||||
|
svg.setAttribute("height", "52");
|
||||||
|
svg.setAttribute("preserveAspectRatio", "none");
|
||||||
|
|
||||||
|
return svg.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
|
function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
|
||||||
const isIndonesian = locale === "id";
|
const isIndonesian = locale === "id";
|
||||||
const safeReceiptNo = escapeHtml(detail.receipt_no);
|
const safeReceiptNo = escapeHtml(detail.receipt_no);
|
||||||
@ -597,14 +638,25 @@ function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
|
function buildLotLabelsPrintHtml(
|
||||||
|
detail: ReceiptDetail,
|
||||||
|
locale: string,
|
||||||
|
printableLots: Array<{
|
||||||
|
lot: ReceiptDetail["generated_lots"][number];
|
||||||
|
qrValue: string;
|
||||||
|
barcodeValue: string;
|
||||||
|
qrDataUrl: string;
|
||||||
|
barcodeSvg: string;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const isIndonesian = locale === "id";
|
const isIndonesian = locale === "id";
|
||||||
const labels = detail.generated_lots.map((lot) => {
|
const labels = printableLots.map(({ lot, qrValue, barcodeValue, qrDataUrl, barcodeSvg }) => {
|
||||||
const safeLotCode = escapeHtml(lot.lot_code);
|
const safeLotCode = escapeHtml(lot.lot_code);
|
||||||
const safeGrade = escapeHtml(lot.grade?.name ?? "-");
|
const safeGrade = escapeHtml(lot.grade?.name ?? "-");
|
||||||
const safeWarehouse = escapeHtml(lot.warehouse.name);
|
const safeWarehouse = escapeHtml(lot.warehouse.name);
|
||||||
const safeLocation = escapeHtml(lot.location?.name ?? "-");
|
const safeLocation = escapeHtml(lot.location?.name ?? "-");
|
||||||
const codeValue = escapeHtml(lot.qr_code_value || lot.barcode_value || lot.lot_code);
|
const safeQrValue = escapeHtml(qrValue);
|
||||||
|
const safeBarcodeValue = escapeHtml(barcodeValue);
|
||||||
|
|
||||||
return `<section class="label">
|
return `<section class="label">
|
||||||
<div class="label-head">
|
<div class="label-head">
|
||||||
@ -614,13 +666,22 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="status">${escapeHtml(lot.status)}</div>
|
<div class="status">${escapeHtml(lot.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="barcode">${codeValue}</div>
|
<div class="body-grid">
|
||||||
|
<div class="qr-box">
|
||||||
|
<img src="${qrDataUrl}" alt="QR ${safeLotCode}" />
|
||||||
|
<p class="scan-value">${safeQrValue}</p>
|
||||||
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div><span>Grade</span><strong>${safeGrade}</strong></div>
|
<div><span>Grade</span><strong>${safeGrade}</strong></div>
|
||||||
<div><span>Qty</span><strong>${escapeHtml(formatQuantity(lot.original_qty, locale, lot.unit.code))}</strong></div>
|
<div><span>Qty</span><strong>${escapeHtml(formatQuantity(lot.original_qty, locale, lot.unit.code))}</strong></div>
|
||||||
<div><span>${isIndonesian ? "Gudang" : "Warehouse"}</span><strong>${safeWarehouse}</strong></div>
|
<div><span>${isIndonesian ? "Gudang" : "Warehouse"}</span><strong>${safeWarehouse}</strong></div>
|
||||||
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></div>
|
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="barcode">
|
||||||
|
<div class="barcode-svg">${barcodeSvg}</div>
|
||||||
|
<p class="scan-value">${safeBarcodeValue}</p>
|
||||||
|
</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span>${isIndonesian ? "Receipt" : "Receipt"} ${escapeHtml(detail.receipt_no)}</span>
|
<span>${isIndonesian ? "Receipt" : "Receipt"} ${escapeHtml(detail.receipt_no)}</span>
|
||||||
<span>${escapeHtml(detail.receipt_date)}</span>
|
<span>${escapeHtml(detail.receipt_date)}</span>
|
||||||
@ -636,13 +697,18 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
<style>
|
<style>
|
||||||
body { margin: 0; background: #f1f5f9; color: #111827; font-family: Arial, Helvetica, sans-serif; }
|
body { margin: 0; background: #f1f5f9; color: #111827; font-family: Arial, Helvetica, sans-serif; }
|
||||||
.sheet { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 16px; }
|
.sheet { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 16px; }
|
||||||
.label { break-inside: avoid; background: #fff; border: 1.5px solid #111827; border-radius: 6px; padding: 14px; min-height: 190px; display: flex; flex-direction: column; gap: 12px; }
|
.label { break-inside: avoid; background: #fff; border: 1.5px solid #111827; border-radius: 6px; padding: 10px; min-height: 50mm; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.label-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
.label-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
.eyebrow { font-size: 9px; font-weight: 800; color: #64748b; letter-spacing: 0; }
|
.eyebrow { font-size: 9px; font-weight: 800; color: #64748b; letter-spacing: 0; }
|
||||||
h2 { margin: 3px 0 0; font-size: 24px; letter-spacing: 0; }
|
h2 { margin: 3px 0 0; font-size: 18px; letter-spacing: 0; word-break: break-word; }
|
||||||
.status { border: 1px solid #111827; padding: 4px 7px; font-size: 10px; font-weight: 800; }
|
.status { border: 1px solid #111827; padding: 4px 7px; font-size: 10px; font-weight: 800; }
|
||||||
.barcode { border: 1px solid #cbd5e1; padding: 9px; text-align: center; font-family: "Courier New", monospace; font-size: 18px; font-weight: 800; letter-spacing: 2px; overflow-wrap: anywhere; }
|
.body-grid { display: grid; grid-template-columns: 34mm 1fr; gap: 8px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
.qr-box { border: 1px solid #cbd5e1; border-radius: 6px; padding: 5px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||||
|
.qr-box img { width: 28mm; height: 28mm; }
|
||||||
|
.barcode { border: 1px solid #cbd5e1; border-radius: 6px; padding: 5px 7px; }
|
||||||
|
.barcode-svg { height: 52px; }
|
||||||
|
.scan-value { margin: 3px 0 0; text-align: center; font-size: 9px; font-weight: 800; letter-spacing: 0; word-break: break-all; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr; gap: 6px; }
|
||||||
.grid div { border-top: 1px solid #e2e8f0; padding-top: 6px; }
|
.grid div { border-top: 1px solid #e2e8f0; padding-top: 6px; }
|
||||||
.grid span { display: block; font-size: 9px; color: #64748b; font-weight: 800; text-transform: uppercase; }
|
.grid span { display: block; font-size: 9px; color: #64748b; font-weight: 800; text-transform: uppercase; }
|
||||||
.grid strong { display: block; margin-top: 2px; font-size: 12px; }
|
.grid strong { display: block; margin-top: 2px; font-size: 12px; }
|
||||||
@ -650,7 +716,7 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
|
|||||||
@media print {
|
@media print {
|
||||||
body { background: #fff; }
|
body { background: #fff; }
|
||||||
.sheet { padding: 0; gap: 8mm; }
|
.sheet { padding: 0; gap: 8mm; }
|
||||||
.label { border-radius: 0; min-height: 48mm; }
|
.label { border-radius: 0; min-height: 50mm; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user