Compare commits

...

5 Commits

6 changed files with 754 additions and 44 deletions

View File

@ -14,6 +14,115 @@ Dokumen ini menyimpan konteks kerja terbaru setelah rangkaian patch mobile, logi
## 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
- Menu `Receipt` di mobile sudah dikeluarkan dari bootstrap mobile.

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

View File

@ -9,6 +9,12 @@ Dokumen ini menyiapkan deploy production untuk:
- source code dari git `https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git`
- 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
Siapkan:

View File

@ -19,15 +19,19 @@ function matchesPath(currentPath: string, href: string) {
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) {
const { dict } = useLocale();
const navigation = useMemo(() => getNavigationForRole(userRole as AppRole), [userRole]);
const buildOpenGroups = () => {
const grouped = navigation.filter(isNavGroup);
return grouped.reduce<Record<string, boolean>>((acc, item) => {
const isGroupActive = item.children.some(
(child) => matchesPath(pathname, child.href)
);
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
acc[item.key] = isGroupActive;
return acc;
}, {});
@ -39,9 +43,7 @@ export function MobileNav({ pathname, userRole }: MobileNavProps) {
setOpenGroups((current) => {
const next = { ...current };
navigation.filter(isNavGroup).forEach((item) => {
const isGroupActive = item.children.some(
(child) => matchesPath(pathname, child.href)
);
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
if (isGroupActive) {
next[item.key] = true;
}
@ -55,9 +57,8 @@ export function MobileNav({ pathname, userRole }: MobileNavProps) {
<div className="space-y-2 pb-1">
{navigation.map((item) => {
if (isNavGroup(item)) {
const isGroupActive = item.children.some(
(child) => matchesPath(pathname, child.href)
);
const activeChildHref = findActiveHref(pathname, item.children);
const isGroupActive = Boolean(activeChildHref);
const isOpen = openGroups[item.key] ?? isGroupActive;
return (
<div key={item.key} className="rounded border border-line/70 bg-slate-50">
@ -83,8 +84,7 @@ export function MobileNav({ pathname, userRole }: MobileNavProps) {
{isOpen ? (
<div className="grid gap-2 border-t border-line/70 bg-white p-2">
{item.children.map((child) => {
const isActive =
matchesPath(pathname, child.href);
const isActive = activeChildHref === child.href;
return (
<Link
key={child.href}

View File

@ -20,15 +20,19 @@ function matchesPath(currentPath: string, href: string) {
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) {
const { dict } = useLocale();
const navigation = useMemo(() => getNavigationForRole(user.role as never), [user.role]);
const buildOpenGroups = () => {
const grouped = navigation.filter(isNavGroup);
return grouped.reduce<Record<string, boolean>>((acc, item) => {
const isGroupActive = item.children.some(
(child) => matchesPath(pathname, child.href)
);
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
acc[item.key] = isGroupActive;
return acc;
}, {});
@ -40,9 +44,7 @@ export function Sidebar({ pathname, user }: SidebarProps) {
setOpenGroups((current) => {
const next = { ...current };
navigation.filter(isNavGroup).forEach((item) => {
const isGroupActive = item.children.some(
(child) => matchesPath(pathname, child.href)
);
const isGroupActive = Boolean(findActiveHref(pathname, item.children));
if (isGroupActive) {
next[item.key] = true;
}
@ -70,9 +72,8 @@ export function Sidebar({ pathname, user }: SidebarProps) {
<nav className="space-y-1 pr-1">
{navigation.map((item) => {
if (isNavGroup(item)) {
const isGroupActive = item.children.some(
(child) => matchesPath(pathname, child.href)
);
const activeChildHref = findActiveHref(pathname, item.children);
const isGroupActive = Boolean(activeChildHref);
const isOpen = openGroups[item.key] ?? isGroupActive;
return (
@ -108,8 +109,7 @@ export function Sidebar({ pathname, user }: SidebarProps) {
{isOpen ? (
<div className="mt-1 space-y-1 pl-3">
{item.children.map((child) => {
const isActive =
matchesPath(pathname, child.href);
const isActive = activeChildHref === child.href;
return (
<Link

View File

@ -2,6 +2,8 @@
import { Printer, Tags } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import JsBarcode from "jsbarcode";
import QRCode from "qrcode";
import { useLocale } from "@/components/providers/locale-provider";
import { composeGradeLabel } from "@/lib/grade-display";
@ -264,7 +266,7 @@ export function ReceiptsClient() {
}
}
function printLotLabels(detail: ReceiptDetail) {
async function printLotLabels(detail: ReceiptDetail) {
setError(null);
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.");
@ -279,7 +281,29 @@ export function ReceiptsClient() {
try {
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();
} catch (err) {
if (!printWindow.closed) {
@ -407,13 +431,13 @@ export function ReceiptsClient() {
<span className={item.status === "FINALIZED" ? "ops-chip-active" : "ops-chip-muted"}>{item.status}</span>
</td>
<td>
<div className="flex gap-2">
<button type="button" onClick={() => void openReceipt(item.id)} className="ops-btn-secondary">{dict.common.detail}</button>
{item.status !== "FINALIZED" ? <button type="button" onClick={() => void generateLots(item.id)} className="ops-btn-primary">{dict.receipts.generateLots}</button> : null}
<div className="flex flex-nowrap gap-2">
<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 whitespace-nowrap">{dict.receipts.generateLots}</button> : null}
<button
type="button"
onClick={() => void printReceiptById(item.id)}
className="ops-btn-secondary"
className="ops-btn-secondary whitespace-nowrap"
>
<Printer className="h-4 w-4" />
{locale === "id" ? "Receipt" : "Receipt"}
@ -422,7 +446,7 @@ export function ReceiptsClient() {
<button
type="button"
onClick={() => void printLotLabelsById(item.id)}
className="ops-btn-secondary"
className="ops-btn-secondary whitespace-nowrap"
>
<Tags className="h-4 w-4" />
{locale === "id" ? "Label lot" : "Lot labels"}
@ -452,7 +476,7 @@ export function ReceiptsClient() {
{locale === "id" ? "Cetak receipt" : "Print receipt"}
</button>
{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" />
{locale === "id" ? "Cetak label lot" : "Print lot labels"}
</button>
@ -497,6 +521,23 @@ function escapeHtml(value: string) {
.replaceAll("'", "&#39;");
}
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) {
const isIndonesian = locale === "id";
const safeReceiptNo = escapeHtml(detail.receipt_no);
@ -597,14 +638,25 @@ function buildReceiptPrintHtml(detail: ReceiptDetail, locale: string) {
</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 labels = detail.generated_lots.map((lot) => {
const labels = printableLots.map(({ lot, qrValue, barcodeValue, qrDataUrl, barcodeSvg }) => {
const safeLotCode = escapeHtml(lot.lot_code);
const safeGrade = escapeHtml(lot.grade?.name ?? "-");
const safeWarehouse = escapeHtml(lot.warehouse.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">
<div class="label-head">
@ -614,12 +666,21 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
</div>
<div class="status">${escapeHtml(lot.status)}</div>
</div>
<div class="barcode">${codeValue}</div>
<div class="grid">
<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>${isIndonesian ? "Gudang" : "Warehouse"}</span><strong>${safeWarehouse}</strong></div>
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></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><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>${isIndonesian ? "Gudang" : "Warehouse"}</span><strong>${safeWarehouse}</strong></div>
<div><span>${isIndonesian ? "Lokasi" : "Location"}</span><strong>${safeLocation}</strong></div>
</div>
</div>
<div class="barcode">
<div class="barcode-svg">${barcodeSvg}</div>
<p class="scan-value">${safeBarcodeValue}</p>
</div>
<div class="foot">
<span>${isIndonesian ? "Receipt" : "Receipt"} ${escapeHtml(detail.receipt_no)}</span>
@ -636,13 +697,18 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
<style>
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; }
.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; }
.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; }
.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; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.body-grid { display: grid; grid-template-columns: 34mm 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 span { display: block; font-size: 9px; color: #64748b; font-weight: 800; text-transform: uppercase; }
.grid strong { display: block; margin-top: 2px; font-size: 12px; }
@ -650,7 +716,7 @@ function buildLotLabelsPrintHtml(detail: ReceiptDetail, locale: string) {
@media print {
body { background: #fff; }
.sheet { padding: 0; gap: 8mm; }
.label { border-radius: 0; min-height: 48mm; }
.label { border-radius: 0; min-height: 50mm; }
}
</style>
</head>