Update mobile purchase flow and locale formatting
This commit is contained in:
@ -1,208 +0,0 @@
|
|||||||
# Codex Handoff - 2026-05-10
|
|
||||||
|
|
||||||
Dokumen ini menyimpan konteks kerja terakhir agar pengerjaan bisa langsung dilanjutkan saat project dibuka lagi.
|
|
||||||
|
|
||||||
## Status Umum
|
|
||||||
|
|
||||||
- App aktif dikembangkan di `Next.js + Prisma`.
|
|
||||||
- Banyak flow bisnis inti sudah tersambung end-to-end.
|
|
||||||
- Project belum dianggap production-ready; fokus saat ini masih penyelesaian fitur dan perapihan UX.
|
|
||||||
|
|
||||||
## Perubahan Domain dan Terminologi
|
|
||||||
|
|
||||||
- `Customer` sudah dirapikan menjadi `Buyer`.
|
|
||||||
- `Seller` sudah dirapikan menjadi `Sales`.
|
|
||||||
- `sellerCurrencyCode` sudah dirapikan menjadi `companyCurrencyCode`.
|
|
||||||
- `item type` dan `item grade` sudah dibersihkan dari kontrak aktif UI/API; flow aktif sekarang pakai `grade`.
|
|
||||||
- Struktur Prisma dan mapping database sudah disesuaikan ke arah penamaan yang lebih jelas.
|
|
||||||
|
|
||||||
## Fitur yang Sudah Aktif
|
|
||||||
|
|
||||||
### 1. Penjualan Reguler
|
|
||||||
|
|
||||||
- Flow create dan close sudah aktif.
|
|
||||||
- Create:
|
|
||||||
- buyer
|
|
||||||
- mata uang buyer
|
|
||||||
- mata uang perusahaan
|
|
||||||
- rate buyer ke perusahaan bila beda currency
|
|
||||||
- kurir
|
|
||||||
- biaya kirim
|
|
||||||
- resi opsional
|
|
||||||
- multiple lot
|
|
||||||
- Line item:
|
|
||||||
- pilih lot
|
|
||||||
- berat jual
|
|
||||||
- harga jual
|
|
||||||
- info grade, kode lot, berat sekarang, harga MAL, bagi hasil agent
|
|
||||||
- Close:
|
|
||||||
- berat jual aktual
|
|
||||||
- berat retur
|
|
||||||
- harga jual aktual
|
|
||||||
- selisih otomatis masuk penyusutan
|
|
||||||
- Komisi agent:
|
|
||||||
- dihitung ulang saat close
|
|
||||||
- menambah saldo agent
|
|
||||||
|
|
||||||
### 2. Titip Jual / Consignment
|
|
||||||
|
|
||||||
- Flow create dan close sudah aktif.
|
|
||||||
- Close support:
|
|
||||||
- berat terjual
|
|
||||||
- berat kembali
|
|
||||||
- berat susut
|
|
||||||
- harga jual
|
|
||||||
- komisi sales manual
|
|
||||||
- Rumus komisi agent:
|
|
||||||
- `((berat terjual * (harga jual - harga MAL)) - komisi sales) * persentase share`
|
|
||||||
- Komisi agent:
|
|
||||||
- menambah saldo bagi hasil agent
|
|
||||||
- Komisi sales:
|
|
||||||
- punya histori sendiri di master `Sales`
|
|
||||||
|
|
||||||
### 3. Penjualan Just In Time
|
|
||||||
|
|
||||||
- Placeholder sudah diganti menjadi flow aktif.
|
|
||||||
- Header input sama arah umumnya dengan penjualan reguler.
|
|
||||||
- Barang dijual tanpa masuk gudang dan tanpa retur.
|
|
||||||
- Line item JIT:
|
|
||||||
- grade
|
|
||||||
- quantity
|
|
||||||
- harga MAL
|
|
||||||
- harga jual
|
|
||||||
- agent opsional
|
|
||||||
- skema bagi hasil opsional
|
|
||||||
- catatan
|
|
||||||
- Status create:
|
|
||||||
- `OPEN`
|
|
||||||
- Close:
|
|
||||||
- update `harga jual aktual` saja
|
|
||||||
- Komisi agent:
|
|
||||||
- dihitung saat close
|
|
||||||
- menambah saldo bagi hasil agent
|
|
||||||
- Banyak pass UI sudah dilakukan agar gaya JIT mendekati penjualan reguler.
|
|
||||||
|
|
||||||
### 4. Purchases
|
|
||||||
|
|
||||||
- Menu `Purchases` sudah dipecah:
|
|
||||||
- `Pembelian Reguler`
|
|
||||||
- `Pembelian Kantor / Buyout Agent`
|
|
||||||
- `Purchase Detail` sekarang dibuka sebagai popup/modal agar tidak merusak layout utama.
|
|
||||||
- Tombol `Cetak receipt` dipindah ke detail purchase, bukan lagi di halaman receipt.
|
|
||||||
|
|
||||||
### 5. Pembelian Kantor / Buyout Agent
|
|
||||||
|
|
||||||
- Sudah aktif sebagai submenu terpisah.
|
|
||||||
- Sumber hanya dari lot aktif yang sudah ada.
|
|
||||||
- Buyout bisa parsial atau full.
|
|
||||||
- Setiap buyout selalu membuat lot baru milik kantor.
|
|
||||||
- Lot lama dikurangi; jika habis bisa closed.
|
|
||||||
- Komisi agent saat buyout:
|
|
||||||
- `max(0, qty * (harga buyout - harga MAL)) * persen share`
|
|
||||||
- Jika harga buyout di bawah harga MAL, komisi agent default `0`.
|
|
||||||
- Komisi buyout menambah saldo bagi hasil agent dan masuk histori.
|
|
||||||
- Detail lot sudah menampilkan jejak buyout.
|
|
||||||
|
|
||||||
### 6. Fund Request
|
|
||||||
|
|
||||||
- Menu baru `/fund-requests` sudah aktif.
|
|
||||||
- Ada 2 tipe:
|
|
||||||
- `Dana modal`
|
|
||||||
- `Bagi hasil`
|
|
||||||
- Input:
|
|
||||||
- code generated
|
|
||||||
- no reff
|
|
||||||
- agent
|
|
||||||
- rekening agent
|
|
||||||
- rekening kantor
|
|
||||||
- nominal
|
|
||||||
- waktu transfer
|
|
||||||
- bukti transfer opsional
|
|
||||||
- Dampak saldo:
|
|
||||||
- `Dana modal`: kantor transfer ke agent, jadi menambah saldo modal agent
|
|
||||||
- `Bagi hasil`: pembayaran bagi hasil ke agent, jadi mengurangi saldo bagi hasil agent
|
|
||||||
- Histori mutasi agent ikut tercatat.
|
|
||||||
|
|
||||||
### 7. Master Agent
|
|
||||||
|
|
||||||
- Agent punya dua saldo:
|
|
||||||
- `saldo bagi hasil`
|
|
||||||
- `saldo modal`
|
|
||||||
- Histori mutasi saldo sudah aktif.
|
|
||||||
- Sumber histori mencakup:
|
|
||||||
- opening balance
|
|
||||||
- manual adjustment
|
|
||||||
- consignment commission
|
|
||||||
- regular sale commission
|
|
||||||
- JIT sale commission
|
|
||||||
- office buyout commission
|
|
||||||
- fund request profit share
|
|
||||||
- fund request capital
|
|
||||||
- Detail agent sudah bisa melihat histori.
|
|
||||||
|
|
||||||
### 8. Master Sales
|
|
||||||
|
|
||||||
- `Sales` punya `commission_balance`.
|
|
||||||
- Histori komisi sales sudah aktif.
|
|
||||||
- Detail sales sudah bisa menampilkan histori komisi.
|
|
||||||
|
|
||||||
### 9. Settings / Profil Perusahaan
|
|
||||||
|
|
||||||
- Profil perusahaan sekarang mendukung multiple rekening kantor.
|
|
||||||
- Bank kantor dipilih dari master bank.
|
|
||||||
- Fund Request memakai pilihan rekening kantor yang dipilih user.
|
|
||||||
- Bug sesudah save yang membuat list rekening kantor terlihat kosong tanpa refresh sudah diperbaiki.
|
|
||||||
|
|
||||||
## Perapihan UI/UX yang Sudah Dilakukan
|
|
||||||
|
|
||||||
- Banyak halaman master 2 kolom sekarang sudah punya:
|
|
||||||
- pagination
|
|
||||||
- search
|
|
||||||
- Picker grade besar sudah diganti ke searchable combobox di beberapa flow aktif.
|
|
||||||
- Sidebar bug auto-expand karena prefix path sudah diperbaiki.
|
|
||||||
- Icon bell di topbar di-hide.
|
|
||||||
- Tombol `?` diarahkan ke halaman bantuan `/help`.
|
|
||||||
- Beberapa halaman transaksi besar sudah dipadatkan dan disamakan skalanya.
|
|
||||||
|
|
||||||
## Catatan Teknis Penting
|
|
||||||
|
|
||||||
- Untuk banyak perubahan schema terakhir, `npm run prisma:generate`, `npm run db:push`, dan `npm run typecheck` sudah pernah lolos.
|
|
||||||
- Alur kerja saat ini masih banyak mengandalkan `db push`, belum migrasi Prisma versioned.
|
|
||||||
- Belum ada test suite otomatis yang matang.
|
|
||||||
- Project sebelumnya juga sudah dinilai belum production-ready, terutama pada aspek auth/bootstrap dev account, secret fallback, migration discipline, dan testing.
|
|
||||||
|
|
||||||
## Isu yang Baru Saja Diperbaiki
|
|
||||||
|
|
||||||
- `Settings`:
|
|
||||||
- setelah simpan, list rekening kantor sempat terlihat reset
|
|
||||||
- akar masalah: response `PUT /api/v1/settings` tidak mengembalikan relasi `companyBankAccounts`
|
|
||||||
- sudah diperbaiki dengan fetch ulang record lengkap sebelum serialize
|
|
||||||
- `Pembelian Reguler`:
|
|
||||||
- warning `lengkapi master data` sempat muncul terus saat buka halaman
|
|
||||||
- sudah diperbaiki agar hanya muncul setelah loading selesai dan hanya jika master wajib benar-benar kosong
|
|
||||||
- `Purchase Analysis`:
|
|
||||||
- chip jumlah purchase sudah dipaksa satu baris
|
|
||||||
|
|
||||||
## Kandidat Lanjutan Paling Masuk Akal
|
|
||||||
|
|
||||||
1. Lanjut desain dan implementasi flow `Pembelian Just In Time` jika memang masih ada versi purchase-side yang terpisah dari `Sales JIT`.
|
|
||||||
2. Sweep UI consistency lagi untuk:
|
|
||||||
- `Sales JIT`
|
|
||||||
- `Fund Request`
|
|
||||||
- `Office Buyout`
|
|
||||||
3. Tambah dokumentasi user flow untuk transaksi baru:
|
|
||||||
- regular sale
|
|
||||||
- JIT sale
|
|
||||||
- office buyout
|
|
||||||
- fund request
|
|
||||||
4. Hardening project sebelum production:
|
|
||||||
- hapus bootstrap user dev dari runtime login
|
|
||||||
- wajibkan `AUTH_SECRET`
|
|
||||||
- pindah ke Prisma migrations
|
|
||||||
- tambah test smoke/integration
|
|
||||||
- rate limiting auth endpoint
|
|
||||||
|
|
||||||
## File Ini
|
|
||||||
|
|
||||||
- Update file ini setiap kali ada keputusan bisnis besar atau modul baru selesai.
|
|
||||||
- Kalau mau lebih formal, file ini bisa diganti nanti menjadi `docs/project-status.md` permanen.
|
|
||||||
243
docs/codex-handoff-2026-05-19.md
Normal file
243
docs/codex-handoff-2026-05-19.md
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# Codex Handoff - 2026-05-19
|
||||||
|
|
||||||
|
Dokumen ini menyimpan konteks kerja terbaru agar pengerjaan bisa langsung dilanjutkan tanpa menggali ulang repo.
|
||||||
|
|
||||||
|
## Status Umum
|
||||||
|
|
||||||
|
- App aktif dikembangkan di `Next.js + Prisma + PostgreSQL`.
|
||||||
|
- Fitur operasional inti sudah cukup lengkap: pembelian, penerimaan, lot, aktivitas stok, penjualan, analisis pembelian, dan realisasi pembelian.
|
||||||
|
- Scope mobile API untuk fase operasional utama juga sudah disiapkan.
|
||||||
|
- Repo sudah dipush ke:
|
||||||
|
`https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git`
|
||||||
|
- Branch aktif:
|
||||||
|
`main`
|
||||||
|
|
||||||
|
## Commit Penting Terakhir
|
||||||
|
|
||||||
|
- `14bb9bf` `Initial import of AbelBirdNest Stock`
|
||||||
|
- `9141f99` `Fix grade seed path for production deploy`
|
||||||
|
- `1274f2b` `Harden login and refresh production deploy guide`
|
||||||
|
- `8e8912e` `Allow negative purchase moisture percentages`
|
||||||
|
|
||||||
|
## Perubahan Besar yang Sudah Selesai
|
||||||
|
|
||||||
|
### 1. Purchase Analysis
|
||||||
|
|
||||||
|
- Snapshot analisis pembelian sudah dipersist agar saat edit nilainya kembali sama persis.
|
||||||
|
- Field seperti:
|
||||||
|
- `modal_barang`
|
||||||
|
- `modal_beli`
|
||||||
|
- `modal_masuk`
|
||||||
|
- `modal_jual`
|
||||||
|
- `total_modal_beli`
|
||||||
|
- `total_modal_mal`
|
||||||
|
- `average_price`
|
||||||
|
- `kadar akhir`
|
||||||
|
sudah tersimpan dan bisa dipakai ulang.
|
||||||
|
- Banyak rumus dashboard/analisis sudah disesuaikan:
|
||||||
|
- `kadar akhir`
|
||||||
|
- `laba total / kg`
|
||||||
|
- `laba/rugi agen`
|
||||||
|
- basis berat gram vs kg
|
||||||
|
|
||||||
|
### 2. Purchase Realization
|
||||||
|
|
||||||
|
- Modul realization sudah didesain dan diimplementasikan bertahap.
|
||||||
|
- Prisma model baru sudah ada:
|
||||||
|
- `LotPurchaseAllocation`
|
||||||
|
- `PurchaseRealizationEntry`
|
||||||
|
- `PurchaseRealizationSummary`
|
||||||
|
- Event yang sudah tersambung ke realization:
|
||||||
|
- purchase submit
|
||||||
|
- office buyout
|
||||||
|
- washing complete
|
||||||
|
- lot transformation / regrade / mix
|
||||||
|
- regular sale close
|
||||||
|
- consignment close
|
||||||
|
- Endpoint list/detail dan halaman UI `Purchase Realization` sudah ada.
|
||||||
|
- Filter/search dan drill-down dasar ke dokumen asal juga sudah ditambahkan.
|
||||||
|
|
||||||
|
### 3. Mobile API
|
||||||
|
|
||||||
|
- Namespace `/api/v1/mobile/**` sudah disiapkan untuk role:
|
||||||
|
- `WAREHOUSE`
|
||||||
|
- `QC`
|
||||||
|
- `SALES`
|
||||||
|
- `PURCHASING`
|
||||||
|
- `OWNER`
|
||||||
|
- Endpoint mobile yang sudah ada mencakup:
|
||||||
|
- bootstrap
|
||||||
|
- dashboard
|
||||||
|
- lots
|
||||||
|
- receipts
|
||||||
|
- stock adjustments
|
||||||
|
- washing
|
||||||
|
- lot transformations
|
||||||
|
- regular sales
|
||||||
|
- JIT sales
|
||||||
|
- consignments
|
||||||
|
- purchases
|
||||||
|
- fund requests
|
||||||
|
- purchase analyses
|
||||||
|
- purchase realizations
|
||||||
|
- Dokumen pendukung:
|
||||||
|
- [mobile-api-blueprint.md](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/mobile-api-blueprint.md)
|
||||||
|
- [abelbirdnest-mobile-api.postman_collection.json](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/postman/abelbirdnest-mobile-api.postman_collection.json)
|
||||||
|
|
||||||
|
### 4. Bilingual UI dan Terminologi
|
||||||
|
|
||||||
|
- Banyak area UI sudah dibersihkan dari campuran istilah Indonesia/Inggris.
|
||||||
|
- Sidebar, mobile nav, halaman transaksi utama, master data, dan modul analitis sudah jauh lebih konsisten.
|
||||||
|
- Banyak label dropdown dan placeholder sudah dibuat locale-aware.
|
||||||
|
- Istilah bisnis Indonesia sudah dirapikan, misalnya:
|
||||||
|
- `Agent` → `Agen`
|
||||||
|
- `Courier` → `Jasa Pengiriman`
|
||||||
|
- `Warehouse` → `Gudang`
|
||||||
|
- `Warehouse Location` → `Lokasi Gudang`
|
||||||
|
- `Contact Person` → `Kontak PIC`
|
||||||
|
|
||||||
|
### 5. Dashboard
|
||||||
|
|
||||||
|
- Dashboard atas sudah diubah:
|
||||||
|
- `Nilai Inventory` menjadi basis `Nilai Pembelian Bulan Ini`
|
||||||
|
- `Receipt Bulan Ini` menjadi `Pembelian Bulan Ini`
|
||||||
|
- grafik menjadi `Tren Pembelian vs Penjualan`
|
||||||
|
- kartu kanan menjadi `Tingkat Serap Penjualan`
|
||||||
|
- Widget lot kritis diperjelas menjadi `Lot Perlu Perhatian`
|
||||||
|
dengan alasan:
|
||||||
|
- `Stok Rendah`
|
||||||
|
- `Usia Tinggi`
|
||||||
|
- `Ditahan`
|
||||||
|
|
||||||
|
### 6. Users
|
||||||
|
|
||||||
|
- User sekarang sudah bisa diedit dari UI.
|
||||||
|
- Jika email diubah:
|
||||||
|
- `email_verified_at` di-reset
|
||||||
|
- token verifikasi lama dibuang
|
||||||
|
- email verifikasi dikirim ulang
|
||||||
|
|
||||||
|
### 7. Regular Purchase Moisture Validation
|
||||||
|
|
||||||
|
- Validasi pembelian reguler sekarang mengizinkan:
|
||||||
|
- `kadar beli`
|
||||||
|
- `kadar masuk`
|
||||||
|
- `kadar akhir`
|
||||||
|
bernilai negatif sampai `-100`.
|
||||||
|
- Alasan bisnis:
|
||||||
|
nilai minus dipakai untuk kasus penyusutan / penurunan berat pada alur pembelian dan analisis.
|
||||||
|
- Patch ini ada di commit:
|
||||||
|
- `8e8912e` `Allow negative purchase moisture percentages`
|
||||||
|
- File yang berubah:
|
||||||
|
- [src/features/purchases/schemas/purchase.schema.ts](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/src/features/purchases/schemas/purchase.schema.ts)
|
||||||
|
- Tidak perlu migration database untuk patch ini.
|
||||||
|
- Update server cukup:
|
||||||
|
- `git pull origin main`
|
||||||
|
- `npm run build`
|
||||||
|
- restart service `abelbirdnest-web`
|
||||||
|
|
||||||
|
## Hardening Production yang Sudah Dilakukan
|
||||||
|
|
||||||
|
- Bootstrap akun default sekarang dimatikan di production lewat:
|
||||||
|
- `AUTH_BOOTSTRAP=false`
|
||||||
|
- `AUTH_SECRET` dan `APP_URL` dipaksa aman via helper runtime.
|
||||||
|
- Health endpoint tersedia di:
|
||||||
|
- `/api/v1/health`
|
||||||
|
- Security header dasar ditambahkan di `next.config.ts`.
|
||||||
|
- Workflow Prisma production-ready sudah disiapkan:
|
||||||
|
- `npm run prisma:migrate:deploy`
|
||||||
|
- File deploy yang sudah ada:
|
||||||
|
- [deploy/nginx/abelbirdnest.id.conf](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/nginx/abelbirdnest.id.conf)
|
||||||
|
- [deploy/nginx/abelbirdnest.id.http.conf](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/nginx/abelbirdnest.id.http.conf)
|
||||||
|
- [deploy/systemd/abelbirdnest-web.service](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/systemd/abelbirdnest-web.service)
|
||||||
|
- [deploy-production.md](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/deploy-production.md)
|
||||||
|
|
||||||
|
## Kondisi Deploy Server yang Perlu Diingat
|
||||||
|
|
||||||
|
- Domain target production:
|
||||||
|
- `abelbirdnest.id`
|
||||||
|
- App dijalankan di:
|
||||||
|
- `127.0.0.1:3007`
|
||||||
|
- Reverse proxy:
|
||||||
|
- `nginx`
|
||||||
|
- Struktur repo aktual di server diasumsikan:
|
||||||
|
- `/var/www/abelbirdnest-web/AbelBirdNest-Stock`
|
||||||
|
- `systemd` service file repo sudah diarahkan ke struktur subfolder ini.
|
||||||
|
- Nginx sekarang disiapkan 2 tahap:
|
||||||
|
1. HTTP-only dulu dengan `abelbirdnest.id.http.conf`
|
||||||
|
2. setelah sertifikat ada, ganti ke `abelbirdnest.id.conf`
|
||||||
|
|
||||||
|
## Catatan Produksi Penting yang Masih Relevan
|
||||||
|
|
||||||
|
- `roles` tidak dibuat otomatis jika `AUTH_BOOTSTRAP=false`.
|
||||||
|
Jadi sebelum membuat user pertama di production, tabel `roles` harus diisi dulu secara manual.
|
||||||
|
- User pertama production sebaiknya dibuat langsung di PostgreSQL sebagai `SYSTEM_ADMIN`.
|
||||||
|
- Seed:
|
||||||
|
- `banks` dan `currencies` bisa langsung
|
||||||
|
- `grades` butuh file `Grade.xls`
|
||||||
|
- Script `seed:grades` sudah tidak lagi hardcoded ke path Mac lokal.
|
||||||
|
Default path sekarang:
|
||||||
|
- `scripts/data/Grade.xls`
|
||||||
|
|
||||||
|
## Perubahan Login dan Footer
|
||||||
|
|
||||||
|
- Halaman login sudah dibersihkan:
|
||||||
|
- tidak ada lagi autofill email/password default
|
||||||
|
- panel daftar akun dev dihapus
|
||||||
|
- Footer copyright sudah dipasang:
|
||||||
|
- `© 2026 AbelBirdnest`
|
||||||
|
- Copyright sudah muncul di:
|
||||||
|
- halaman login
|
||||||
|
- seluruh halaman app utama via `AppShell`
|
||||||
|
|
||||||
|
## Dokumentasi User yang Sudah Dibuat
|
||||||
|
|
||||||
|
Sudah ada dokumen manual Word di folder `docs/`:
|
||||||
|
|
||||||
|
- [manual-dashboard-abelbirdnest.docx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/manual-dashboard-abelbirdnest.docx)
|
||||||
|
- [manual-operasional-abelbirdnest-lengkap.docx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/manual-operasional-abelbirdnest-lengkap.docx)
|
||||||
|
- [manual-operasional-abelbirdnest-formal-screenshot.docx](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/manual-operasional-abelbirdnest-formal-screenshot.docx)
|
||||||
|
|
||||||
|
Dan asset screenshot pendukung:
|
||||||
|
|
||||||
|
- [docs/manual-assets](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/manual-assets)
|
||||||
|
|
||||||
|
Catatan:
|
||||||
|
|
||||||
|
- file manual dan asset screenshot saat ini masih `untracked` di git
|
||||||
|
- belum dipush ke remote
|
||||||
|
|
||||||
|
## Status Git Saat Handoff Ini Dibuat
|
||||||
|
|
||||||
|
Untracked:
|
||||||
|
|
||||||
|
- `docs/manual-assets/`
|
||||||
|
- `docs/manual-dashboard-abelbirdnest.docx`
|
||||||
|
- `docs/manual-operasional-abelbirdnest-lengkap.docx`
|
||||||
|
- `docs/manual-operasional-abelbirdnest-formal-screenshot.docx`
|
||||||
|
|
||||||
|
## Hal yang Masih Belum Ideal
|
||||||
|
|
||||||
|
- Belum ada rate limiting auth/API sensitif.
|
||||||
|
- `SMTP_PASSWORD` masih bisa tersimpan di DB settings.
|
||||||
|
- `npm run lint` belum dijadikan gate CI non-interaktif yang rapi.
|
||||||
|
- Belum ada test suite otomatis yang matang.
|
||||||
|
- Sebagian validasi/error backend mungkin masih belum sepenuhnya locale-aware walaupun UI utama sudah jauh lebih bersih.
|
||||||
|
|
||||||
|
## Langkah Lanjutan Paling Masuk Akal
|
||||||
|
|
||||||
|
1. Putuskan apakah dokumen manual Word + screenshot perlu masuk repo dan dipush.
|
||||||
|
2. Jika iya, `git add` file manual dan asset screenshot lalu commit terpisah.
|
||||||
|
3. Tambahkan script/helper resmi untuk membuat `roles` dan user production pertama agar tidak lagi manual SQL.
|
||||||
|
4. Lanjut hardening production:
|
||||||
|
- rate limit
|
||||||
|
- cleanup SMTP secret strategy
|
||||||
|
- CI/lint/test
|
||||||
|
5. Jika mobile app mulai dikerjakan, pakai:
|
||||||
|
- `docs/mobile-api-blueprint.md`
|
||||||
|
- Postman collection mobile
|
||||||
|
|
||||||
|
## Catatan Penutup
|
||||||
|
|
||||||
|
- File handoff lama `docs/codex-handoff-2026-05-10.md` sudah tidak relevan lagi karena banyak perubahan besar setelah tanggal itu.
|
||||||
|
- Handoff terbaru ini menjadi sumber konteks utama sampai ada update berikutnya.
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Deploy Production
|
# Deploy Production
|
||||||
|
|
||||||
Panduan ini untuk deploy production dengan asumsi:
|
Dokumen ini menyiapkan deploy production untuk:
|
||||||
|
|
||||||
- domain `abelbirdnest.id`
|
- domain `abelbirdnest.id`
|
||||||
- reverse proxy `nginx`
|
- reverse proxy `nginx`
|
||||||
@ -8,26 +8,26 @@ Panduan ini untuk deploy production dengan asumsi:
|
|||||||
- database `PostgreSQL`
|
- database `PostgreSQL`
|
||||||
- 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`
|
||||||
- repo berada di:
|
|
||||||
`/var/www/abelbirdnest-web/AbelBirdNest-Stock`
|
|
||||||
|
|
||||||
## 1. Persiapan Server
|
## 1. Persiapan Server
|
||||||
|
|
||||||
Install:
|
Siapkan:
|
||||||
|
|
||||||
- Node.js LTS
|
- Node.js LTS
|
||||||
- npm
|
- npm
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- nginx
|
- nginx
|
||||||
- certbot
|
- certbot / SSL Let’s Encrypt
|
||||||
|
|
||||||
Direktori aplikasi:
|
Direktori contoh:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/var/www/abelbirdnest-web
|
/var/www/abelbirdnest-web
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Buat User OS Khusus Aplikasi
|
## 2. Buat User Khusus Aplikasi
|
||||||
|
|
||||||
|
Jalankan sebagai `root` atau dengan `sudo`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo useradd -r -m -d /var/www/abelbirdnest-web -s /bin/bash abelbirdnest
|
sudo useradd -r -m -d /var/www/abelbirdnest-web -s /bin/bash abelbirdnest
|
||||||
@ -35,44 +35,35 @@ sudo mkdir -p /var/www/abelbirdnest-web
|
|||||||
sudo chown -R abelbirdnest:abelbirdnest /var/www/abelbirdnest-web
|
sudo chown -R abelbirdnest:abelbirdnest /var/www/abelbirdnest-web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Catatan:
|
||||||
|
|
||||||
|
- user `abelbirdnest` dipakai khusus untuk menjalankan service aplikasi
|
||||||
|
- jangan jalankan app production dengan user pribadi atau `root`
|
||||||
|
|
||||||
|
## 3. Clone Repo dari Git
|
||||||
|
|
||||||
Masuk sebagai user aplikasi:
|
Masuk sebagai user aplikasi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u abelbirdnest -H bash
|
sudo -u abelbirdnest -H bash
|
||||||
cd /var/www/abelbirdnest-web
|
cd /var/www/abelbirdnest-web
|
||||||
|
git clone https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git .
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Clone Repo
|
Kalau server butuh autentikasi git internal, siapkan credential sesuai kebijakan server Git Anda.
|
||||||
|
|
||||||
Clone normal:
|
## 4. Environment Production
|
||||||
|
|
||||||
```bash
|
Salin `.env.production.example` menjadi `.env.production`, lalu isi nilainya.
|
||||||
git clone https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git
|
|
||||||
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
|
||||||
```
|
|
||||||
|
|
||||||
Catatan:
|
Yang wajib:
|
||||||
|
|
||||||
- panduan ini mengikuti struktur clone normal di atas
|
|
||||||
- jadi `WorkingDirectory`, `.env.production`, dan semua perintah memakai path:
|
|
||||||
`/var/www/abelbirdnest-web/AbelBirdNest-Stock`
|
|
||||||
|
|
||||||
## 4. Siapkan Environment Production
|
|
||||||
|
|
||||||
Salin file contoh:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.production.example .env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
Isi minimal:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3007
|
PORT=3007
|
||||||
APP_URL=https://abelbirdnest.id
|
APP_URL=https://abelbirdnest.id
|
||||||
DATABASE_URL=postgresql://abelbirdnest_app:password@127.0.0.1:5432/abelbirdnest_prod?schema=public
|
DATABASE_URL=postgresql://...
|
||||||
AUTH_SECRET=ganti-dengan-random-string-panjang
|
AUTH_SECRET=...
|
||||||
AUTH_BOOTSTRAP=false
|
AUTH_BOOTSTRAP=false
|
||||||
SMTP_HOST=...
|
SMTP_HOST=...
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
@ -84,13 +75,20 @@ SMTP_FROM=...
|
|||||||
|
|
||||||
Catatan:
|
Catatan:
|
||||||
|
|
||||||
- `AUTH_BOOTSTRAP=false` wajib di production
|
- `AUTH_SECRET` harus random panjang.
|
||||||
- akun default dev tidak akan aktif
|
- `AUTH_BOOTSTRAP=false` wajib untuk production.
|
||||||
- `AUTH_SECRET` harus random dan panjang
|
- `APP_URL` harus domain production final.
|
||||||
|
|
||||||
## 5. Inisialisasi PostgreSQL
|
## 5. Inisialisasi Database PostgreSQL
|
||||||
|
|
||||||
Masuk sebagai superuser PostgreSQL:
|
Contoh di bawah memakai:
|
||||||
|
|
||||||
|
- database: `abelbirdnest_prod`
|
||||||
|
- database user: `abelbirdnest_app`
|
||||||
|
- host: `127.0.0.1`
|
||||||
|
- port: `5432`
|
||||||
|
|
||||||
|
Masuk ke PostgreSQL sebagai superuser:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u postgres psql
|
sudo -u postgres psql
|
||||||
@ -99,19 +97,29 @@ sudo -u postgres psql
|
|||||||
Buat user database:
|
Buat user database:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE USER abelbirdnest_app WITH PASSWORD 'ganti-dengan-password-yang-kuat';
|
CREATE USER abelbirdnest_app WITH PASSWORD '72ed04ddd2bb520eacae7f4b71a16b0d';
|
||||||
```
|
```
|
||||||
|
|
||||||
Buat database:
|
Buat database production:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE DATABASE abelbirdnest_prod OWNER abelbirdnest_app;
|
CREATE DATABASE abelbirdnest_prod OWNER abelbirdnest_app;
|
||||||
|
```
|
||||||
|
|
||||||
|
Pastikan owner database benar:
|
||||||
|
|
||||||
|
```sql
|
||||||
ALTER DATABASE abelbirdnest_prod OWNER TO abelbirdnest_app;
|
ALTER DATABASE abelbirdnest_prod OWNER TO abelbirdnest_app;
|
||||||
|
```
|
||||||
|
|
||||||
|
Opsional tapi disarankan, kunci privilege default:
|
||||||
|
|
||||||
|
```sql
|
||||||
REVOKE ALL ON DATABASE abelbirdnest_prod FROM PUBLIC;
|
REVOKE ALL ON DATABASE abelbirdnest_prod FROM PUBLIC;
|
||||||
GRANT ALL PRIVILEGES ON DATABASE abelbirdnest_prod TO abelbirdnest_app;
|
GRANT ALL PRIVILEGES ON DATABASE abelbirdnest_prod TO abelbirdnest_app;
|
||||||
```
|
```
|
||||||
|
|
||||||
Keluar:
|
Keluar dari `psql`:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
\q
|
\q
|
||||||
@ -120,295 +128,156 @@ Keluar:
|
|||||||
Tes koneksi:
|
Tes koneksi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
psql "postgresql://abelbirdnest_app:ganti-dengan-password-yang-kuat@127.0.0.1:5432/abelbirdnest_prod"
|
psql "postgresql://abelbirdnest_app:72ed04ddd2bb520eacae7f4b71a16b0d@127.0.0.1:5432/abelbirdnest_prod?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika koneksi berhasil, pakai URL itu di `.env.production`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://abelbirdnest_app:ganti-dengan-password-yang-kuat@127.0.0.1:5432/abelbirdnest_prod?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Install Dependency, Database & Migration
|
||||||
|
|
||||||
|
Repo ini sudah disiapkan memakai migration Prisma.
|
||||||
|
|
||||||
|
Jalankan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/abelbirdnest-web
|
||||||
|
npm install
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run prisma:migrate:deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Kalau perlu isi master awal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed:master
|
||||||
|
```
|
||||||
|
|
||||||
|
Data seed yang dibawa:
|
||||||
|
|
||||||
|
- grade
|
||||||
|
- bank
|
||||||
|
- currency
|
||||||
|
|
||||||
|
Urutan pertama kali untuk fresh database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/abelbirdnest-web
|
||||||
|
npm install
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run prisma:migrate:deploy
|
||||||
|
npm run seed:master
|
||||||
```
|
```
|
||||||
|
|
||||||
Catatan:
|
Catatan:
|
||||||
|
|
||||||
- untuk `psql`, jangan pakai `?schema=public`
|
- `prisma:migrate:deploy` akan membuat seluruh tabel dari migration yang ada di repo
|
||||||
- untuk `DATABASE_URL` Prisma, tetap pakai `?schema=public`
|
- `seed:master` hanya mengisi data awal `grade`, `bank`, dan `currency`
|
||||||
|
- user login production tetap harus dibuat terpisah, jangan mengandalkan akun dev/default
|
||||||
|
|
||||||
## 6. Install Dependency dan Buat Tabel
|
## 7. Build Production
|
||||||
|
|
||||||
Masih sebagai user `abelbirdnest`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
|
||||||
npm install
|
|
||||||
npm run prisma:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
Load env production ke shell saat menjalankan command manual:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -a
|
|
||||||
source .env.production
|
|
||||||
set +a
|
|
||||||
```
|
|
||||||
|
|
||||||
Jalankan migration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run prisma:migrate:deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
Opsional cek status:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate status
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Seed Data Awal
|
|
||||||
|
|
||||||
Untuk fresh database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
|
||||||
set -a
|
|
||||||
source .env.production
|
|
||||||
set +a
|
|
||||||
|
|
||||||
npm run seed:banks
|
|
||||||
npm run seed:currencies
|
|
||||||
```
|
|
||||||
|
|
||||||
### Khusus Grade
|
|
||||||
|
|
||||||
`seed:grades` butuh file `Grade.xls`.
|
|
||||||
|
|
||||||
Buat folder data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p scripts/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Upload file `Grade.xls` ke:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/var/www/abelbirdnest-web/AbelBirdNest-Stock/scripts/data/Grade.xls
|
|
||||||
```
|
|
||||||
|
|
||||||
Lalu jalankan:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
|
||||||
set -a
|
|
||||||
source .env.production
|
|
||||||
set +a
|
|
||||||
|
|
||||||
npm run seed:grades
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatif jika file ada di lokasi lain:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node scripts/seed-grades-from-xls.mjs /path/ke/Grade.xls
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Buat User Pertama di PostgreSQL
|
|
||||||
|
|
||||||
Karena `AUTH_BOOTSTRAP=false`, Anda harus buat user login pertama sendiri.
|
|
||||||
|
|
||||||
### 8.1 Generate Hash Password
|
|
||||||
|
|
||||||
Ganti password contoh ini:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node -e 'const {randomBytes,scryptSync}=require("crypto"); const p="GantiPasswordKuat123!"; const salt=randomBytes(16).toString("hex"); const derived=scryptSync(p,salt,64).toString("hex"); console.log(`${salt}:${derived}`)'
|
|
||||||
```
|
|
||||||
|
|
||||||
Simpan output hash-nya.
|
|
||||||
|
|
||||||
### 8.2 Masuk ke PostgreSQL
|
|
||||||
|
|
||||||
```bash
|
|
||||||
psql "postgresql://abelbirdnest_app:password@127.0.0.1:5432/abelbirdnest_prod"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 Pastikan Role `SYSTEM_ADMIN` Ada
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO roles (code, name, created_at, updated_at)
|
|
||||||
VALUES ('SYSTEM_ADMIN', 'System Admin', NOW(), NOW())
|
|
||||||
ON CONFLICT (code) DO UPDATE
|
|
||||||
SET name = EXCLUDED.name,
|
|
||||||
updated_at = NOW();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4 Buat User Pertama
|
|
||||||
|
|
||||||
Ganti:
|
|
||||||
|
|
||||||
- `Nama Anda`
|
|
||||||
- `superadmin`
|
|
||||||
- `superadmin@abelbirdnest.id`
|
|
||||||
- `HASH_HASIL_LANGKAH_8_1`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO users (
|
|
||||||
role_id,
|
|
||||||
name,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
email_verified_at,
|
|
||||||
phone,
|
|
||||||
password_hash,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.id,
|
|
||||||
'Nama Anda',
|
|
||||||
'superadmin',
|
|
||||||
'superadmin@abelbirdnest.id',
|
|
||||||
NOW(),
|
|
||||||
NULL,
|
|
||||||
'HASH_HASIL_LANGKAH_8_1',
|
|
||||||
'ACTIVE',
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
FROM roles r
|
|
||||||
WHERE r.code = 'SYSTEM_ADMIN'
|
|
||||||
ON CONFLICT (email) DO UPDATE
|
|
||||||
SET role_id = EXCLUDED.role_id,
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
username = EXCLUDED.username,
|
|
||||||
email_verified_at = EXCLUDED.email_verified_at,
|
|
||||||
password_hash = EXCLUDED.password_hash,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
updated_at = NOW();
|
|
||||||
```
|
|
||||||
|
|
||||||
Verifikasi:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT u.id, u.name, u.username, u.email, u.status, r.code AS role
|
|
||||||
FROM users u
|
|
||||||
JOIN roles r ON r.id = u.role_id
|
|
||||||
WHERE u.email = 'superadmin@abelbirdnest.id';
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. Build Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
|
||||||
set -a
|
|
||||||
source .env.production
|
|
||||||
set +a
|
|
||||||
|
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 10. Jalankan App dengan systemd
|
## 8. Jalankan App di Port 3007
|
||||||
|
|
||||||
File service repo sudah disiapkan untuk struktur subfolder ini:
|
Manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3007 npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
Atau gunakan `systemd` dari:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
deploy/systemd/abelbirdnest-web.service
|
deploy/systemd/abelbirdnest-web.service
|
||||||
```
|
```
|
||||||
|
|
||||||
Pasang:
|
Contoh setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp /var/www/abelbirdnest-web/AbelBirdNest-Stock/deploy/systemd/abelbirdnest-web.service /etc/systemd/system/
|
sudo cp deploy/systemd/abelbirdnest-web.service /etc/systemd/system/
|
||||||
sudo chown -R abelbirdnest:abelbirdnest /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable abelbirdnest-web
|
sudo systemctl enable abelbirdnest-web
|
||||||
sudo systemctl restart abelbirdnest-web
|
sudo systemctl start abelbirdnest-web
|
||||||
sudo systemctl status abelbirdnest-web
|
sudo systemctl status abelbirdnest-web
|
||||||
```
|
```
|
||||||
|
|
||||||
Verifikasi port:
|
Autostart saat server restart terjadi karena service di-`enable`.
|
||||||
|
|
||||||
|
Untuk verifikasi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ss -ltnp | grep 3007
|
sudo systemctl is-enabled abelbirdnest-web
|
||||||
curl http://127.0.0.1:3007/api/v1/health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Kalau service gagal, cek:
|
## 9. Reverse Proxy Nginx
|
||||||
|
|
||||||
|
Gunakan file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl cat abelbirdnest-web
|
deploy/nginx/abelbirdnest.id.conf
|
||||||
sudo journalctl -u abelbirdnest-web -n 100 --no-pager
|
|
||||||
which npm
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11. Pasang Nginx Tahap 1: HTTP Dulu
|
|
||||||
|
|
||||||
Jangan langsung pakai config HTTPS sebelum sertifikat ada.
|
|
||||||
|
|
||||||
Pakai file ini dulu:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deploy/nginx/abelbirdnest.id.http.conf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pasang:
|
Pasang:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp /var/www/abelbirdnest-web/AbelBirdNest-Stock/deploy/nginx/abelbirdnest.id.http.conf /etc/nginx/sites-available/abelbirdnest.id.conf
|
sudo cp deploy/nginx/abelbirdnest.id.conf /etc/nginx/sites-available/abelbirdnest.id.conf
|
||||||
sudo ln -sf /etc/nginx/sites-available/abelbirdnest.id.conf /etc/nginx/sites-enabled/abelbirdnest.id.conf
|
sudo ln -s /etc/nginx/sites-available/abelbirdnest.id.conf /etc/nginx/sites-enabled/abelbirdnest.id.conf
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
sudo systemctl reload nginx
|
sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
Tes:
|
## 10. Health Check
|
||||||
|
|
||||||
|
Endpoint health:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://abelbirdnest.id/api/v1/health
|
GET /api/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. Buat Sertifikat SSL
|
Contoh:
|
||||||
|
|
||||||
Setelah HTTP sudah hidup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo certbot --nginx -d abelbirdnest.id -d www.abelbirdnest.id
|
|
||||||
```
|
|
||||||
|
|
||||||
## 13. Ganti ke Config HTTPS Final
|
|
||||||
|
|
||||||
Setelah sertifikat berhasil dibuat, baru pakai config final:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp /var/www/abelbirdnest-web/AbelBirdNest-Stock/deploy/nginx/abelbirdnest.id.conf /etc/nginx/sites-available/abelbirdnest.id.conf
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
Verifikasi:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl https://abelbirdnest.id/api/v1/health
|
curl https://abelbirdnest.id/api/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## 14. Update Deployment Berikutnya
|
## 11. Update Deployment Berikutnya
|
||||||
|
|
||||||
|
Jika aplikasi sudah live dan ada update dari git:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
cd /var/www/abelbirdnest-web
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|
||||||
set -a
|
|
||||||
source .env.production
|
|
||||||
set +a
|
|
||||||
|
|
||||||
npm install
|
npm install
|
||||||
npm run prisma:generate
|
|
||||||
npm run prisma:migrate:deploy
|
npm run prisma:migrate:deploy
|
||||||
npm run build
|
npm run build
|
||||||
sudo systemctl restart abelbirdnest-web
|
sudo systemctl restart abelbirdnest-web
|
||||||
sudo systemctl status abelbirdnest-web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 15. Smoke Test Setelah Live
|
Jika branch utama nanti bukan `main`, sesuaikan perintah `git pull`.
|
||||||
|
|
||||||
Cek minimal:
|
## 12. Checklist Go-Live
|
||||||
|
|
||||||
- login dengan user `SYSTEM_ADMIN`
|
- `AUTH_BOOTSTRAP=false`
|
||||||
- dashboard terbuka
|
- `AUTH_SECRET` sudah production-grade
|
||||||
- health check OK
|
- `APP_URL=https://abelbirdnest.id`
|
||||||
- buat master `bank`, `currency`, `grade` tampil
|
- SSL aktif
|
||||||
- buat transaksi sederhana
|
- database backup aktif
|
||||||
- logout/login ulang
|
- `npm run build` lulus
|
||||||
- email reset/verifikasi jika SMTP sudah aktif
|
- `npm run prisma:migrate:deploy` lulus
|
||||||
|
- `npm run seed:master` selesai jika dibutuhkan
|
||||||
|
- login, reset password, dan email verifikasi sudah dites
|
||||||
|
- create purchase, receipt, lot, sale sudah dites
|
||||||
|
|
||||||
|
## 13. Catatan Penting
|
||||||
|
|
||||||
|
- Jangan pakai `npm run db:push` untuk production.
|
||||||
|
- Jangan pakai akun default development.
|
||||||
|
- Jangan simpan `.env.production` di repo.
|
||||||
|
- Pastikan ownership file tetap `abelbirdnest:abelbirdnest`.
|
||||||
|
|||||||
BIN
docs/manual-assets/dashboard-lot-perhatian.png
Normal file
BIN
docs/manual-assets/dashboard-lot-perhatian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
docs/manual-assets/login.png
Normal file
BIN
docs/manual-assets/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
docs/manual-assets/purchase-analysis.png
Normal file
BIN
docs/manual-assets/purchase-analysis.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
BIN
docs/manual-assets/sidebar-menu.png
Normal file
BIN
docs/manual-assets/sidebar-menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/manual-dashboard-abelbirdnest.docx
Normal file
BIN
docs/manual-dashboard-abelbirdnest.docx
Normal file
Binary file not shown.
BIN
docs/manual-operasional-abelbirdnest-formal-screenshot.docx
Normal file
BIN
docs/manual-operasional-abelbirdnest-formal-screenshot.docx
Normal file
Binary file not shown.
BIN
docs/manual-operasional-abelbirdnest-lengkap.docx
Normal file
BIN
docs/manual-operasional-abelbirdnest-lengkap.docx
Normal file
Binary file not shown.
@ -46,8 +46,7 @@ Semua role mobile memakai:
|
|||||||
|
|
||||||
Tujuan:
|
Tujuan:
|
||||||
- scan lot
|
- scan lot
|
||||||
- buat receipt
|
- submit purchase yang otomatis membuat receipt dan lot
|
||||||
- generate lot
|
|
||||||
- buat penyesuaian stok
|
- buat penyesuaian stok
|
||||||
- kirim / selesaikan washing
|
- kirim / selesaikan washing
|
||||||
|
|
||||||
@ -57,11 +56,9 @@ Endpoint:
|
|||||||
- `GET /api/v1/mobile/lots`
|
- `GET /api/v1/mobile/lots`
|
||||||
- `GET /api/v1/mobile/lots/:id`
|
- `GET /api/v1/mobile/lots/:id`
|
||||||
- `GET /api/v1/mobile/lots/scan?code=...`
|
- `GET /api/v1/mobile/lots/scan?code=...`
|
||||||
- `GET /api/v1/mobile/receipts/bootstrap`
|
- `GET /api/v1/mobile/purchases`
|
||||||
- `GET /api/v1/mobile/receipts`
|
- `GET /api/v1/mobile/purchases/:id`
|
||||||
- `POST /api/v1/mobile/receipts`
|
- `POST /api/v1/mobile/purchases/:id/submit`
|
||||||
- `GET /api/v1/mobile/receipts/:id`
|
|
||||||
- `POST /api/v1/mobile/receipts/:id/generate-lots`
|
|
||||||
- `GET /api/v1/mobile/stock-adjustments/bootstrap`
|
- `GET /api/v1/mobile/stock-adjustments/bootstrap`
|
||||||
- `GET /api/v1/mobile/stock-adjustments`
|
- `GET /api/v1/mobile/stock-adjustments`
|
||||||
- `POST /api/v1/mobile/stock-adjustments`
|
- `POST /api/v1/mobile/stock-adjustments`
|
||||||
@ -185,8 +182,8 @@ Layar minimum:
|
|||||||
2. Dashboard ringkas
|
2. Dashboard ringkas
|
||||||
3. Scan lot
|
3. Scan lot
|
||||||
4. Detail lot
|
4. Detail lot
|
||||||
5. Receipt baru
|
5. Daftar pembelian siap submit
|
||||||
6. Detail receipt + generate lot
|
6. Submit pembelian yang otomatis membuat receipt + lot
|
||||||
7. Penyesuaian stok
|
7. Penyesuaian stok
|
||||||
8. Daftar washing
|
8. Daftar washing
|
||||||
9. Buat washing
|
9. Buat washing
|
||||||
|
|||||||
@ -11,7 +11,6 @@ export async function GET(request: Request) {
|
|||||||
OWNER: [
|
OWNER: [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"lots",
|
"lots",
|
||||||
"receipts",
|
|
||||||
"washing",
|
"washing",
|
||||||
"stock_adjustments",
|
"stock_adjustments",
|
||||||
"lot_transformations",
|
"lot_transformations",
|
||||||
@ -33,7 +32,6 @@ export async function GET(request: Request) {
|
|||||||
WAREHOUSE: [
|
WAREHOUSE: [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"lots",
|
"lots",
|
||||||
"receipts",
|
|
||||||
"washing",
|
"washing",
|
||||||
"stock_adjustments"
|
"stock_adjustments"
|
||||||
],
|
],
|
||||||
@ -54,7 +52,6 @@ export async function GET(request: Request) {
|
|||||||
ADMIN: [
|
ADMIN: [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"lots",
|
"lots",
|
||||||
"receipts",
|
|
||||||
"washing",
|
"washing",
|
||||||
"stock_adjustments",
|
"stock_adjustments",
|
||||||
"lot_transformations",
|
"lot_transformations",
|
||||||
@ -69,7 +66,6 @@ export async function GET(request: Request) {
|
|||||||
SYSTEM_ADMIN: [
|
SYSTEM_ADMIN: [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"lots",
|
"lots",
|
||||||
"receipts",
|
|
||||||
"washing",
|
"washing",
|
||||||
"stock_adjustments",
|
"stock_adjustments",
|
||||||
"lot_transformations",
|
"lot_transformations",
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { requireApiAccess } from "@/lib/authorization";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
|
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
|
||||||
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
|
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
|
||||||
|
import { generateReceiptNo } from "@/features/receipts/lib/generate-receipt-no";
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
type SubmitTx = Prisma.TransactionClient & {
|
type SubmitTx = Prisma.TransactionClient & {
|
||||||
@ -36,6 +37,12 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
include: {
|
include: {
|
||||||
agent: { select: { id: true, name: true } },
|
agent: { select: { id: true, name: true } },
|
||||||
profitShareScheme: { select: { id: true, shareAgent: true } },
|
profitShareScheme: { select: { id: true, shareAgent: true } },
|
||||||
|
receipts: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
receiptNo: true
|
||||||
|
}
|
||||||
|
},
|
||||||
lines: {
|
lines: {
|
||||||
include: {
|
include: {
|
||||||
grade: true,
|
grade: true,
|
||||||
@ -52,6 +59,13 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
return NextResponse.json({ message: "Purchase sudah disubmit" }, { status: 409 });
|
return NextResponse.json({ message: "Purchase sudah disubmit" }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (purchase.receipts.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: `Purchase sudah memiliki receipt ${purchase.receipts[0]?.receiptNo ?? ""}`.trim() },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!purchase.receivedByEmployeeId) {
|
if (!purchase.receivedByEmployeeId) {
|
||||||
return NextResponse.json({ message: "Penerima belum dipilih" }, { status: 400 });
|
return NextResponse.json({ message: "Penerima belum dipilih" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@ -61,6 +75,8 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const receivedAt = purchase.receivedAt;
|
const receivedAt = purchase.receivedAt;
|
||||||
|
const receiptDate = new Date(receivedAt.toISOString().slice(0, 10));
|
||||||
|
const receiptNo = await generateReceiptNo(receiptDate);
|
||||||
const sourceCode = purchase.agent?.name || purchase.purchaseNo;
|
const sourceCode = purchase.agent?.name || purchase.purchaseNo;
|
||||||
const enteredQtyLines = purchase.lines.filter((line) => Number(line.qtyAccepted.toNumber()) > 0);
|
const enteredQtyLines = purchase.lines.filter((line) => Number(line.qtyAccepted.toNumber()) > 0);
|
||||||
if (enteredQtyLines.length === 0) {
|
if (enteredQtyLines.length === 0) {
|
||||||
@ -92,8 +108,45 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
throw new Error("Lot untuk purchase ini sudah pernah digenerate");
|
throw new Error("Lot untuk purchase ini sudah pernah digenerate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingReceiptCount = await tx.receipt.count({ where: { purchaseId } });
|
||||||
|
if (existingReceiptCount > 0) {
|
||||||
|
throw new Error("Receipt untuk purchase ini sudah pernah dibuat");
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = await tx.receipt.create({
|
||||||
|
data: {
|
||||||
|
receiptNo,
|
||||||
|
purchaseId: purchase.id,
|
||||||
|
receiptDate,
|
||||||
|
status: "FINALIZED",
|
||||||
|
notes: purchase.notes || null,
|
||||||
|
receivedById: BigInt(auth.user.id)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.receiptLine.createMany({
|
||||||
|
data: enteredQtyLines.map((line) => ({
|
||||||
|
receiptId: receipt.id,
|
||||||
|
purchaseLineId: line.id,
|
||||||
|
gradeId: line.gradeId,
|
||||||
|
qtyReceived: line.qtyReceived,
|
||||||
|
qtyAccepted: line.qtyAccepted,
|
||||||
|
qtyRejected: line.qtyRejected,
|
||||||
|
unitId: line.unitId,
|
||||||
|
unitCost: line.unitCost,
|
||||||
|
warehouseId: line.warehouseId!,
|
||||||
|
warehouseLocationId: line.warehouseLocationId,
|
||||||
|
notes: line.notes || null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const receiptLines = await tx.receiptLine.findMany({
|
||||||
|
where: { receiptId: receipt.id },
|
||||||
|
orderBy: { id: "asc" }
|
||||||
|
});
|
||||||
|
|
||||||
const lots = [];
|
const lots = [];
|
||||||
for (const line of enteredQtyLines) {
|
for (const line of receiptLines) {
|
||||||
if (!line.warehouseId) {
|
if (!line.warehouseId) {
|
||||||
throw new Error("Warehouse belum diisi pada salah satu baris pembelian");
|
throw new Error("Warehouse belum diisi pada salah satu baris pembelian");
|
||||||
}
|
}
|
||||||
@ -107,7 +160,9 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
sourceType: "PURCHASE",
|
sourceType: "PURCHASE",
|
||||||
sourceRefId: purchaseId,
|
sourceRefId: purchaseId,
|
||||||
purchaseId: purchase.id,
|
purchaseId: purchase.id,
|
||||||
purchaseLineId: line.id,
|
purchaseLineId: line.purchaseLineId,
|
||||||
|
receiptId: receipt.id,
|
||||||
|
receiptLineId: line.id,
|
||||||
gradeId: line.gradeId,
|
gradeId: line.gradeId,
|
||||||
warehouseId: line.warehouseId,
|
warehouseId: line.warehouseId,
|
||||||
warehouseLocationId: line.warehouseLocationId,
|
warehouseLocationId: line.warehouseLocationId,
|
||||||
@ -158,7 +213,7 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
amountProfit: new Prisma.Decimal(0),
|
amountProfit: new Prisma.Decimal(0),
|
||||||
agentSharePercentSnapshot: purchase.profitShareScheme?.shareAgent ?? null,
|
agentSharePercentSnapshot: purchase.profitShareScheme?.shareAgent ?? null,
|
||||||
agentAmount: new Prisma.Decimal(0),
|
agentAmount: new Prisma.Decimal(0),
|
||||||
notes: `Opening realization dari purchase line ${line.id.toString()}`
|
notes: `Opening realization dari purchase line ${line.purchaseLineId.toString()}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -181,6 +236,10 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
purchase: updated,
|
purchase: updated,
|
||||||
|
receipt: {
|
||||||
|
id: receipt.id.toString(),
|
||||||
|
receipt_no: receipt.receiptNo
|
||||||
|
},
|
||||||
lots
|
lots
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -193,10 +252,14 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
method: request.method,
|
method: request.method,
|
||||||
pathname: new URL(request.url).pathname,
|
pathname: new URL(request.url).pathname,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
summary: `Purchase ${purchase.purchaseNo} disubmit + lot dibuat`,
|
summary: `Purchase ${purchase.purchaseNo} disubmit + receipt + lot dibuat`,
|
||||||
metadata: buildAuditChangeMetadata(
|
metadata: buildAuditChangeMetadata(
|
||||||
{ status: purchase.status, lot_count: 0 },
|
{ status: purchase.status, lot_count: 0, receipt_no: null },
|
||||||
{ status: generated.purchase.status, lot_count: generated.lots.length }
|
{
|
||||||
|
status: generated.purchase.status,
|
||||||
|
lot_count: generated.lots.length,
|
||||||
|
receipt_no: generated.receipt.receipt_no
|
||||||
|
}
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -208,7 +271,8 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
status: generated.purchase.status,
|
status: generated.purchase.status,
|
||||||
lot_count: generated.lots.length
|
lot_count: generated.lots.length,
|
||||||
|
receipt: generated.receipt
|
||||||
},
|
},
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { LotMixingClient } from "@/features/lot-transformations/components/lot-mixing-client";
|
import { LotMixingClient } from "@/features/lot-transformations/components/lot-mixing-client";
|
||||||
|
import { getAppSettings } from "@/lib/app-settings";
|
||||||
|
|
||||||
export default async function SortingPage() {
|
export default async function SortingPage() {
|
||||||
|
const settings = await getAppSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
pathname="/sorting"
|
pathname="/sorting"
|
||||||
title="Mixing Lot / Ubah Grade"
|
title="Mixing Lot / Ubah Grade"
|
||||||
description="Campurkan beberapa lot aktif menjadi lot hasil baru dengan grade baru, lalu simpan jejak input-output secara penuh."
|
description="Campurkan beberapa lot aktif menjadi lot hasil baru dengan grade baru, lalu simpan jejak input-output secara penuh."
|
||||||
>
|
>
|
||||||
<LotMixingClient />
|
<LotMixingClient currencyCode={settings.currency_code} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { WashingClient } from "@/features/washing/components/washing-client";
|
import { WashingClient } from "@/features/washing/components/washing-client";
|
||||||
|
import { getAppSettings } from "@/lib/app-settings";
|
||||||
|
|
||||||
export default async function WashingPage() {
|
export default async function WashingPage() {
|
||||||
|
const settings = await getAppSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
pathname="/washing"
|
pathname="/washing"
|
||||||
title="Pencucian"
|
title="Pencucian"
|
||||||
description="Kirim lot ke tempat cuci, simpan biaya dan resi, lalu selesaikan hasil cuci dengan pembaruan berat, grade, dan lokasi gudang."
|
description="Kirim lot ke tempat cuci, simpan biaya dan resi, lalu selesaikan hasil cuci dengan pembaruan berat, grade, dan lokasi gudang."
|
||||||
>
|
>
|
||||||
<WashingClient />
|
<WashingClient currencyCode={settings.currency_code} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
|
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
import { SearchableSelectField } from "@/components/shared/searchable-select-field";
|
import { SearchableSelectField } from "@/components/shared/searchable-select-field";
|
||||||
|
import { formatCurrencyAmount, formatDecimal, formatKilogram } from "@/lib/formatters";
|
||||||
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
||||||
import type {
|
import type {
|
||||||
GradeRecord,
|
GradeRecord,
|
||||||
@ -65,7 +66,7 @@ const createEmptyForm = (): TransformationForm => ({
|
|||||||
outputs: [createEmptyOutput()]
|
outputs: [createEmptyOutput()]
|
||||||
});
|
});
|
||||||
|
|
||||||
export function LotMixingClient() {
|
export function LotMixingClient({ currencyCode }: { currencyCode: string }) {
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const [lots, setLots] = useState<LotListItem[]>([]);
|
const [lots, setLots] = useState<LotListItem[]>([]);
|
||||||
const [transformations, setTransformations] = useState<LotTransformationListItem[]>([]);
|
const [transformations, setTransformations] = useState<LotTransformationListItem[]>([]);
|
||||||
@ -478,8 +479,8 @@ export function LotMixingClient() {
|
|||||||
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan sisa regrade" : "Regrade remainder handling"}</p>
|
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan sisa regrade" : "Regrade remainder handling"}</p>
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
{locale === "id"
|
{locale === "id"
|
||||||
? <>Masih ada sisa <span className="font-semibold text-ink">{unusedSourceRemainderQty.toFixed(3)} kg</span> dari lot sumber yang tidak dipakai untuk regrade. Pilih apakah sisa tetap berada di grade asal atau dicatat sebagai susut.</>
|
? <>Masih ada sisa <span className="font-semibold text-ink">{formatKilogram(unusedSourceRemainderQty, locale)}</span> dari lot sumber yang tidak dipakai untuk regrade. Pilih apakah sisa tetap berada di grade asal atau dicatat sebagai susut.</>
|
||||||
: <>There is still <span className="font-semibold text-ink">{unusedSourceRemainderQty.toFixed(3)} kg</span> remaining from the source lot that was not used for regrade. Choose whether the remainder stays in the original grade or is recorded as shrinkage.</>}
|
: <>There is still <span className="font-semibold text-ink">{formatKilogram(unusedSourceRemainderQty, locale)}</span> remaining from the source lot that was not used for regrade. Choose whether the remainder stays in the original grade or is recorded as shrinkage.</>}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<label className="flex items-start gap-3 rounded border border-line/70 bg-white px-4 py-3">
|
<label className="flex items-start gap-3 rounded border border-line/70 bg-white px-4 py-3">
|
||||||
@ -499,8 +500,8 @@ export function LotMixingClient() {
|
|||||||
<div className="font-medium text-ink">{locale === "id" ? "Tetap di grade sebelumnya" : "Keep in previous grade"}</div>
|
<div className="font-medium text-ink">{locale === "id" ? "Tetap di grade sebelumnya" : "Keep in previous grade"}</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{locale === "id"
|
{locale === "id"
|
||||||
? `Sisa ${unusedSourceRemainderQty.toFixed(3)} kg akan tetap berada di lot asal${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`
|
? `Sisa ${formatKilogram(unusedSourceRemainderQty, locale)} akan tetap berada di lot asal${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`
|
||||||
: `The remaining ${unusedSourceRemainderQty.toFixed(3)} kg will stay in the source lot${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`}
|
: `The remaining ${formatKilogram(unusedSourceRemainderQty, locale)} will stay in the source lot${selectedRegradeSourceLot ? ` (${selectedRegradeSourceLot.grade})` : ""}.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -521,8 +522,8 @@ export function LotMixingClient() {
|
|||||||
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut" : "Record as shrinkage"}</div>
|
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut" : "Record as shrinkage"}</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{locale === "id"
|
{locale === "id"
|
||||||
? `Sisa ${unusedSourceRemainderQty.toFixed(3)} kg akan ditambahkan ke shrinkage lot sumber.`
|
? `Sisa ${formatKilogram(unusedSourceRemainderQty, locale)} akan ditambahkan ke shrinkage lot sumber.`
|
||||||
: `The remaining ${unusedSourceRemainderQty.toFixed(3)} kg will be added to source-lot shrinkage.`}
|
: `The remaining ${formatKilogram(unusedSourceRemainderQty, locale)} will be added to source-lot shrinkage.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -615,8 +616,8 @@ export function LotMixingClient() {
|
|||||||
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan selisih hasil ubah grade" : "Regrade output difference handling"}</p>
|
<p className="text-sm font-semibold text-ink">{locale === "id" ? "Penanganan selisih hasil ubah grade" : "Regrade output difference handling"}</p>
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
{locale === "id"
|
{locale === "id"
|
||||||
? <>Total output masih kurang <span className="font-semibold text-ink">{outputLossQty.toFixed(3)} kg</span> dari qty yang dipakai untuk regrade. Anda tidak bisa menyimpan sebelum selisih ini ditangani.</>
|
? <>Total output masih kurang <span className="font-semibold text-ink">{formatKilogram(outputLossQty, locale)}</span> dari qty yang dipakai untuk regrade. Anda tidak bisa menyimpan sebelum selisih ini ditangani.</>
|
||||||
: <>Total output is still short by <span className="font-semibold text-ink">{outputLossQty.toFixed(3)} kg</span> compared with the qty used for regrade. You cannot save before this difference is handled.</>}
|
: <>Total output is still short by <span className="font-semibold text-ink">{formatKilogram(outputLossQty, locale)}</span> compared with the qty used for regrade. You cannot save before this difference is handled.</>}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-slate-600">
|
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-slate-600">
|
||||||
<li>{locale === "id" ? "tambahkan lot hasil baru agar total output pas, atau" : "add a new output lot so total output matches, or"}</li>
|
<li>{locale === "id" ? "tambahkan lot hasil baru agar total output pas, atau" : "add a new output lot so total output matches, or"}</li>
|
||||||
@ -640,8 +641,8 @@ export function LotMixingClient() {
|
|||||||
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut/lost" : "Record as shrinkage/loss"}</div>
|
<div className="font-medium text-ink">{locale === "id" ? "Catat sebagai susut/lost" : "Record as shrinkage/loss"}</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{locale === "id"
|
{locale === "id"
|
||||||
? `Selisih ${outputLossQty.toFixed(3)} kg akan dicatat sebagai shrinkage pada lot sumber.`
|
? `Selisih ${formatKilogram(outputLossQty, locale)} akan dicatat sebagai shrinkage pada lot sumber.`
|
||||||
: `The ${outputLossQty.toFixed(3)} kg difference will be recorded as shrinkage on the source lot.`}
|
: `The ${formatKilogram(outputLossQty, locale)} difference will be recorded as shrinkage on the source lot.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -650,9 +651,9 @@ export function LotMixingClient() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
<SummaryMetric label={locale === "id" ? "Total Qty Input" : "Total Input Qty"} value={inputTotal.toFixed(3)} />
|
<SummaryMetric label={locale === "id" ? "Total Qty Input" : "Total Input Qty"} value={formatDecimal(inputTotal, locale)} />
|
||||||
<SummaryMetric label={locale === "id" ? "Total Qty Output" : "Total Output Qty"} value={outputTotal.toFixed(3)} />
|
<SummaryMetric label={locale === "id" ? "Total Qty Output" : "Total Output Qty"} value={formatDecimal(outputTotal, locale)} />
|
||||||
<SummaryMetric label={locale === "id" ? "Selisih / Shrinkage" : "Difference / Shrinkage"} value={outputLossQty.toFixed(3)} />
|
<SummaryMetric label={locale === "id" ? "Selisih / Shrinkage" : "Difference / Shrinkage"} value={formatDecimal(outputLossQty, locale)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@ -704,8 +705,8 @@ export function LotMixingClient() {
|
|||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td className="font-semibold text-ink">{item.transformation_no}</td>
|
<td className="font-semibold text-ink">{item.transformation_no}</td>
|
||||||
<td className="text-slate-600">{item.transformation_type}</td>
|
<td className="text-slate-600">{item.transformation_type}</td>
|
||||||
<td className="text-slate-600">{item.total_input_qty.toFixed(3)}</td>
|
<td className="text-slate-600">{formatDecimal(item.total_input_qty, locale)}</td>
|
||||||
<td className="text-slate-600">{item.total_output_qty.toFixed(3)}</td>
|
<td className="text-slate-600">{formatDecimal(item.total_output_qty, locale)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="ops-chip-active">{item.status}</span>
|
<span className="ops-chip-active">{item.status}</span>
|
||||||
</td>
|
</td>
|
||||||
@ -744,7 +745,7 @@ export function LotMixingClient() {
|
|||||||
</div>
|
</div>
|
||||||
{selectedTransformation.remainder_qty ? (
|
{selectedTransformation.remainder_qty ? (
|
||||||
<div className="mt-2 text-sm text-slate-600">
|
<div className="mt-2 text-sm text-slate-600">
|
||||||
{locale === "id" ? "Sisa" : "Remainder"}: {selectedTransformation.remainder_qty.toFixed(3)} kg ·{" "}
|
{locale === "id" ? "Sisa" : "Remainder"}: {formatKilogram(selectedTransformation.remainder_qty, locale)} ·{" "}
|
||||||
{selectedTransformation.remainder_mode === "KEEP_SOURCE_GRADE"
|
{selectedTransformation.remainder_mode === "KEEP_SOURCE_GRADE"
|
||||||
? locale === "id" ? "tetap di grade asal" : "kept in original grade"
|
? locale === "id" ? "tetap di grade asal" : "kept in original grade"
|
||||||
: selectedTransformation.remainder_mode === "SHRINKAGE"
|
: selectedTransformation.remainder_mode === "SHRINKAGE"
|
||||||
@ -754,7 +755,7 @@ export function LotMixingClient() {
|
|||||||
) : null}
|
) : null}
|
||||||
{selectedTransformation.processing_loss_qty ? (
|
{selectedTransformation.processing_loss_qty ? (
|
||||||
<div className="mt-2 text-sm text-slate-600">
|
<div className="mt-2 text-sm text-slate-600">
|
||||||
{locale === "id" ? "Selisih output" : "Output difference"}: {selectedTransformation.processing_loss_qty.toFixed(3)} kg ·{" "}
|
{locale === "id" ? "Selisih output" : "Output difference"}: {formatKilogram(selectedTransformation.processing_loss_qty, locale)} ·{" "}
|
||||||
{selectedTransformation.processing_loss_mode === "SHRINKAGE"
|
{selectedTransformation.processing_loss_mode === "SHRINKAGE"
|
||||||
? locale === "id" ? "dicatat sebagai susut/lost" : "recorded as shrinkage/loss"
|
? locale === "id" ? "dicatat sebagai susut/lost" : "recorded as shrinkage/loss"
|
||||||
: "-"}
|
: "-"}
|
||||||
@ -817,7 +818,7 @@ export function LotMixingClient() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-slate-600">
|
<div className="mt-2 text-sm text-slate-600">
|
||||||
{output.result_lot.grade} ·{" "}
|
{output.result_lot.grade} ·{" "}
|
||||||
{locale === "id" ? "Biaya Rp" : "Cost Rp"} {output.unit_cost.toLocaleString(locale === "id" ? "id-ID" : "en-US")}
|
{locale === "id" ? "Biaya" : "Cost"} {formatCurrencyAmount(output.unit_cost, locale, currencyCode, 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
|
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
import { SearchableSelectField } from "@/components/shared/searchable-select-field";
|
import { SearchableSelectField } from "@/components/shared/searchable-select-field";
|
||||||
|
import { formatCurrencyAmount } from "@/lib/formatters";
|
||||||
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
import type { ApiErrorResponse, DetailResponse } from "@/types/api";
|
||||||
import type { LotListItem } from "@/types/lot";
|
import type { LotListItem } from "@/types/lot";
|
||||||
import type {
|
import type {
|
||||||
@ -61,7 +62,7 @@ async function parseJsonResponse<T>(response: Response, context: string): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WashingClient() {
|
export function WashingClient({ currencyCode }: { currencyCode: string }) {
|
||||||
const { dict, locale } = useLocale();
|
const { dict, locale } = useLocale();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const requestedId = searchParams.get("id");
|
const requestedId = searchParams.get("id");
|
||||||
@ -540,7 +541,7 @@ export function WashingClient() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-slate-600">
|
<td className="text-slate-600">
|
||||||
{new Intl.NumberFormat(locale === "id" ? "id-ID" : "en-US").format(item.washing_cost)}
|
{formatCurrencyAmount(item.washing_cost, locale, currencyCode, 0)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={item.status === "DONE" ? "ops-chip-active" : "ops-chip-muted"}>
|
<span className={item.status === "DONE" ? "ops-chip-active" : "ops-chip-muted"}>
|
||||||
|
|||||||
@ -19,6 +19,10 @@ export function formatQuantity(
|
|||||||
return unitCode ? `${formatted} ${unitCode}` : formatted;
|
return unitCode ? `${formatted} ${unitCode}` : formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatKilogram(value: number, locale: string, maximumFractionDigits = 3) {
|
||||||
|
return formatQuantity(value, locale, "kg", maximumFractionDigits);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatCurrencyAmount(
|
export function formatCurrencyAmount(
|
||||||
value: number,
|
value: number,
|
||||||
locale: string,
|
locale: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user