commit 14bb9bf7447d4f2e4951aad2d06dc9d7804d9760 Author: Wira Irawan Date: Sat May 16 18:25:51 2026 +0700 Initial import of AbelBirdNest Stock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1c9f3fa --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/abelbirdnest?schema=public" +AUTH_SECRET="ganti-dengan-secret-random-anda" +APP_URL="http://localhost:3000" +SMTP_HOST="mail.example.com" +SMTP_PORT="465" +SMTP_USER="mailer@example.com" +SMTP_PASSWORD="ganti-dengan-password-smtp" +SMTP_FROM="mailer@example.com" diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..ec43ad2 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,13 @@ +NODE_ENV=production +PORT=3007 +APP_URL=https://abelbirdnest.id +DATABASE_URL="postgresql://USER:PASSWORD@127.0.0.1:5432/abelbirdnest_prod?schema=public" +AUTH_SECRET="ganti-dengan-secret-random-panjang" +AUTH_BOOTSTRAP=false + +SMTP_HOST="mail.abelbirdnest.id" +SMTP_PORT="465" +SMTP_SECURE="true" +SMTP_USER="noreply@abelbirdnest.id" +SMTP_PASSWORD="ganti-dengan-password-smtp" +SMTP_FROM="noreply@abelbirdnest.id" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aba1a5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.next +node_modules +coverage +dist +*.log +.DS_Store +.env +.env.production +.env.local +tsconfig.tsbuildinfo diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ab573b --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# AbelBirdnest Stock + +## Ringkasan +Repo ini berisi aplikasi `AbelBirdnest Stock` beserta paket lengkap dokumen perancangan untuk sistem inventory sarang burung walet berbasis lot/batch, traceability, sortasi, partial allocation penjualan, costing, barcode/QR, dan reporting. + +## Update Terbaru +- master `suppliers` dan `customers` sekarang sudah mencakup field: + - `bank_name` + - `bank_account_number` +- perubahan ini sudah disinkronkan ke ERD, SQL schema, seed data, API/OpenAPI, Postman, mock response, UI data contract, dan dokumen terkait. + +## Isi Paket + +### 1. Business & Product +- `docs/project-spec/walet-alur-bisnis.md` / `.pdf` +- `docs/project-spec/walet-prd.md` / `.pdf` +- `docs/project-spec/walet-sample-transactions.md` / `.pdf` +- `docs/project-spec/walet-notification-approval-flow.md` + +### 2. Database & Backend +- `docs/project-spec/walet-erd-dbml.dbml` +- `docs/project-spec/walet-schema.sql` / `.pdf` +- `docs/project-spec/walet-seed-data.sql` +- `docs/project-spec/walet-backend-architecture.md` +- `docs/project-spec/walet-api-spec.md` +- `docs/project-spec/walet-openapi.yaml` +- `docs/project-spec/walet-report-queries.sql` +- `docs/project-spec/walet-mock-responses.json` + +### 3. Frontend & UX +- `docs/project-spec/walet-wireframe.md` / `.pdf` +- `docs/project-spec/walet-frontend-structure.md` +- `docs/project-spec/walet-component-tree.md` +- `docs/project-spec/walet-ui-data-contract.md` +- `docs/project-spec/walet-role-permission-matrix.md` +- `docs/project-spec/walet-sprint-breakdown.md` + +### 4. API Testing +- `docs/project-spec/walet-postman-collection.json` +- `docs/project-spec/walet-postman-environment.json` + +## Urutan Baca yang Disarankan +1. `docs/project-spec/walet-alur-bisnis.pdf` +2. `docs/project-spec/walet-prd.pdf` +3. `docs/project-spec/walet-erd-dbml.dbml` +4. `docs/project-spec/walet-schema.sql` +5. `docs/project-spec/walet-openapi.yaml` +6. `docs/project-spec/walet-wireframe.pdf` +7. `docs/project-spec/walet-frontend-structure.md` +8. `docs/project-spec/walet-sprint-breakdown.md` + +## Catatan +Fokus desain saat ini tetap pada integritas lot, costing, traceability, serta kelengkapan data pembayaran untuk supplier dan customer. + +## Frontend Setup Awal +Repo ini sekarang sudah memiliki scaffold aplikasi Next.js untuk memulai implementasi UI `AbelBirdnest Stock`. + +### Stack yang dipakai +- Next.js App Router +- React + TypeScript +- Tailwind CSS + +### Menjalankan project +```bash +npm install +npm run dev +``` + +Lalu buka `http://localhost:3000`. + +Nama aplikasi yang tampil di UI adalah `AbelBirdnest Stock`. + +### Menyalakan backend lokal +1. copy `.env.example` menjadi `.env` +2. pastikan PostgreSQL sudah jalan +3. buat database `abelbirdnest` +4. isi `AUTH_SECRET` di `.env` +5. jalankan: + +```bash +npm run prisma:generate +npm run db:push +npm run dev +``` + +### Login Development +- halaman login: `http://localhost:3000/login` +- akun default development dibuat otomatis saat login pertama: + - `ADMIN` + - email: `admin@abelbirdnest.local` + - username: `admin` + - password: `admin123` + - `OWNER` + - email: `owner@abelbirdnest.local` + - username: `owner` + - password: `owner123` + - `PURCHASING` + - email: `purchasing@abelbirdnest.local` + - username: `purchasing` + - password: `purchasing123` + - `WAREHOUSE` + - email: `warehouse@abelbirdnest.local` + - username: `warehouse` + - password: `warehouse123` + - `QC` + - email: `qc@abelbirdnest.local` + - username: `qc` + - password: `qc123` + - `SALES` + - email: `sales@abelbirdnest.local` + - username: `sales` + - password: `sales123` + +### Deploy Production +- contoh env production: [.env.production.example](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/.env.production.example) +- panduan deploy lengkap: [deploy-production.md](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/docs/deploy-production.md) +- contoh `nginx`: [abelbirdnest.id.conf](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/nginx/abelbirdnest.id.conf) +- contoh `systemd`: [abelbirdnest-web.service](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/systemd/abelbirdnest-web.service) + +### Status Saat Ini +- layout aplikasi awal sudah hidup +- halaman dasar sudah tersedia untuk dashboard, suppliers, customers, purchases, receipts, lots, sorting, sales, barcode lookup, dan report stock summary +- modul `suppliers` sudah terhubung ke Prisma + PostgreSQL lewat endpoint `/api/v1/suppliers` +- modul lain masih placeholder dan belum tersambung ke backend/API riil diff --git a/deploy/nginx/abelbirdnest.id.conf b/deploy/nginx/abelbirdnest.id.conf new file mode 100644 index 0000000..cecb18b --- /dev/null +++ b/deploy/nginx/abelbirdnest.id.conf @@ -0,0 +1,37 @@ +server { + listen 80; + listen [::]:80; + server_name abelbirdnest.id www.abelbirdnest.id; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name abelbirdnest.id www.abelbirdnest.id; + + ssl_certificate /etc/letsencrypt/live/abelbirdnest.id/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/abelbirdnest.id/privkey.pem; + + 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 https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120s; + } + + location /api/v1/health { + proxy_pass http://127.0.0.1:3007; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + } +} diff --git a/deploy/systemd/abelbirdnest-web.service b/deploy/systemd/abelbirdnest-web.service new file mode 100644 index 0000000..2be85a6 --- /dev/null +++ b/deploy/systemd/abelbirdnest-web.service @@ -0,0 +1,18 @@ +[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 +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 diff --git a/design-assets/stitch_abel_stock/alokasi_lot_penjualan/code.html b/design-assets/stitch_abel_stock/alokasi_lot_penjualan/code.html new file mode 100644 index 0000000..5bfe902 --- /dev/null +++ b/design-assets/stitch_abel_stock/alokasi_lot_penjualan/code.html @@ -0,0 +1,449 @@ + + + + + + + + + + + + + + +
+
+SwiftLot Walet +
+

Alokasi Lot

+
+
+
+notifications + +
+help_outline +
+User profile +
+
+
+ + + +
+
+ +
+
+
+Sales Order +SO-2023-1082 +
+

Alokasi Lot Produksi

+
+
+ + +
+
+ +
+ +
+
+

+list_alt + Ringkasan Pesanan +

+ +
+
+
MANGKUK GRADE AAA
+
+12.50 kg +Dibutuhkan +
+
+
+
+
+Teralokasi: 8.12 kg +Sisa: 4.38 kg +
+
+
+
SUDUT GRADE A
+
+5.00 kg +Dibutuhkan +
+
+
+
+
+Teralokasi: 0.00 kg +Sisa: 5.00 kg +
+
+
+ +
+
+Total Lot Terpilih +14 Lot +
+
+Estimasi Value +Rp 124.500k +
+
+
+ +
+
+filter_list +Filter Kriteria +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+search + +
+
+ Menampilkan 32 Lot tersedia +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Lot CodeSupplierQty TersediaUnit CostFIFO BadgeAlokasi (kg)
+
+LOT-BRG-2309-001 +Harvest: 12 Sep 2023 +
+
Sari Alam House +
+2.45 kg +MC 12% +
+
14.500.000 +1 + +
+ +kg +
+
+
+LOT-BRG-2309-004 +Harvest: 14 Sep 2023 +
+
Bukit Hijau +
+1.80 kg +MC 11% +
+
14.200.000 +2 + +
+ +
+
+
+LOT-CST-2310-012 +Harvest: 02 Oct 2023 +
+
Mitra Mandiri +
+5.10 kg +MC 14% +
+
13.800.000 +3 + +
+ +
+
+
+LOT-PRM-2310-045 +Harvest: 10 Oct 2023 +
+
Prima Walet +
+3.20 kg +MC 12% +
+
14.100.000 +4 + +
+ +
+
+
+LOT-PRM-2310-048 +Harvest: 11 Oct 2023 +
+
Prima Walet +1.40 kg +14.050.000 +5 + +
+ +
+
+
+ +
+
+
+ +Prioritas FIFO +
+
+ +Terisi (Penuh) +
+
+
+Subtotal Alokasi: 8.12 kg +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/alokasi_lot_penjualan/screen.png b/design-assets/stitch_abel_stock/alokasi_lot_penjualan/screen.png new file mode 100644 index 0000000..9266a7c Binary files /dev/null and b/design-assets/stitch_abel_stock/alokasi_lot_penjualan/screen.png differ diff --git a/design-assets/stitch_abel_stock/daftar_gudang_lokasi/code.html b/design-assets/stitch_abel_stock/daftar_gudang_lokasi/code.html new file mode 100644 index 0000000..e44bcbf --- /dev/null +++ b/design-assets/stitch_abel_stock/daftar_gudang_lokasi/code.html @@ -0,0 +1,454 @@ + + + + + +Daftar Gudang & Lokasi - Sarang Inventory Pro + + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+
+ + + +
+
+
+
+

Admin Manager

+

Warehouse Lead

+
+Manager Avatar +
+
+
+ +
+ +
+
+

Manajemen Gudang

+

Kelola infrastruktur penyimpanan dan hierarki lokasi rak produksi.

+
+ +
+ +
+
+
+warehouse +
+
+

Total Gudang

+

12

+
+
+
+
+view_quilt +
+
+

Total Rak/Lokasi

+

482

+
+
+
+
+inventory +
+
+

Kapasitas Terpakai

+

68%

+
+
+
+
+report_problem +
+
+

Gudang Hampir Penuh

+

2

+
+
+
+ +
+
+

Daftar Gudang Operasional

+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nama GudangKodeAlamat / LokasiKapasitas (Terisi/Total)Jumlah RakAksi
+
+
+home_storage +
+Gudang Utama Raw Material +
+
GDG-RM-01Kawasan Industri Jababeka, Blok C14 +
+
+1,250 kg +/ 1,500 kg +
+
+
+
+
+
+124 Rak + + +
+
+
+home_storage +
+Gudang Pengeringan A +
+
GDG-DRY-AArea Produksi Lt. 2, Sayap Barat +
+
+420 kg +/ 800 kg +
+
+
+
+
+
+86 Rak + + +
+
+
+home_storage +
+Gudang Finishing & QC +
+
GDG-FIN-03Kawasan Industri Jababeka, Blok C15 +
+
+980 kg +/ 1,000 kg +
+
+
+
+
+
+210 Rak + + +
+
+
+home_storage +
+Cold Storage Export +
+
GDG-COLD-01Gedung Kargo Bandara Soekarno-Hatta +
+
+150 kg +/ 500 kg +
+
+
+
+
+
+32 Lokasi + + +
+
+
+

Menampilkan 4 dari 12 gudang terdaftar

+
+ + + + + +
+
+
+ +
+
+
+

Visualisasi Lokasi Geografis

+Live Integration +
+
+World map with location pins +
+location_on +Pusat Operasional: Jababeka, Cikarang +
+
+
+
+

Aktivitas Terkini

+
+
+
14:20
+
+Input Barang: 45kg Material Mentah tiba di GDG-RM-01. +
+
+
+
11:05
+
+Pemindahan: Lot #B203 dipindah dari Rak A2 ke GDG-DRY-A. +
+
+
+
Kemarin
+
+QC Selesai: 12 Rak di GDG-FIN-03 dinyatakan layak ekspor. +
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/daftar_gudang_lokasi/screen.png b/design-assets/stitch_abel_stock/daftar_gudang_lokasi/screen.png new file mode 100644 index 0000000..d9f0244 Binary files /dev/null and b/design-assets/stitch_abel_stock/daftar_gudang_lokasi/screen.png differ diff --git a/design-assets/stitch_abel_stock/daftar_pelanggan_1/code.html b/design-assets/stitch_abel_stock/daftar_pelanggan_1/code.html new file mode 100644 index 0000000..1782e01 --- /dev/null +++ b/design-assets/stitch_abel_stock/daftar_pelanggan_1/code.html @@ -0,0 +1,447 @@ + + + + + +Daftar Pelanggan | SarangTrace ERP + + + + + + + + + +
+
+SarangTrace ERP + +
+
+ + +
+User Profile +
+
+
+ + + +
+
+ +
+
+ +

Daftar Pelanggan

+

Kelola data pelanggan, kontak, dan informasi perbankan dalam satu tempat.

+
+ +
+ +
+
+
+TOTAL PELANGGAN +group +
+
+1,284 ++12% +
+
+
+
+PELANGGAN AKTIF +check_circle +
+
+1,250 +97.3% +
+
+
+
+KREDIT LIMIT TOTAL +account_balance_wallet +
+
+Rp 4.2B +Limit High +
+
+
+
+BARU (BULAN INI) +person_add +
+
+32 +Stable +
+
+
+ +
+
+
+search + +
+ +
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KODE PELANGGANNAMA PELANGGANCONTACT PERSONTELEPONINFO BANKSTATUSAKSI
CUST-JKT-001 +
+PT. Ekspor Walet Kencana +Wholesaler +
+
Bpk. Hendra Wijaya+62 21 5567 8900 +
+BCA - 0089223344 +AN: Hendra Wijaya +
+
+AKTIF + + +
CUST-SBY-042 +
+CV. Sarang Burung Lestari +Local Processor +
+
Ibu Maya Santoso+62 812 3344 5566 +
+Mandiri - 142009887766 +AN: Maya Santoso +
+
+AKTIF + + +
CUST-MDN-018 +
+Global Nest Trading Ltd. +International +
+
Mr. Zhang Wei+65 6789 4432 +
+HSBC - 9988-002-11 +SWIFT: HSBSGSGXXX +
+
+NON-AKTIF + + +
CUST-SMG-005 +
+Semarang Birdnest Hub +Agent +
+
Anwar Hakim+62 24 8876 1122 +
+BNI - 0021334455 +AN: Anwar Hakim +
+
+DITANGGUHKAN + + +
CUST-JKT-099 +
+Prima Walet Utama +Retailer +
+
Siska Amelia+62 856 7788 9900 +
+BRI - 3344-01-009922 +AN: PT. Prima Walet Utama +
+
+AKTIF + + +
+ +
+Menampilkan 1-5 dari 1,284 pelanggan +
+ + +
+ + + +... + +
+ + +
+
+
+ + +
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/daftar_pelanggan_1/screen.png b/design-assets/stitch_abel_stock/daftar_pelanggan_1/screen.png new file mode 100644 index 0000000..2d9dc89 Binary files /dev/null and b/design-assets/stitch_abel_stock/daftar_pelanggan_1/screen.png differ diff --git a/design-assets/stitch_abel_stock/daftar_pelanggan_2/code.html b/design-assets/stitch_abel_stock/daftar_pelanggan_2/code.html new file mode 100644 index 0000000..a6683f9 --- /dev/null +++ b/design-assets/stitch_abel_stock/daftar_pelanggan_2/code.html @@ -0,0 +1,483 @@ + + + + + + + + + + + + + + +
+
+Logo +Sistem Inventaris Walet +
+
+ +
+ + +
+ +Profil Pengguna +
+
+
+ + + +
+
+ +
+
+ +

Daftar Pelanggan

+

Kelola data klien ekspor dan distributor domestik sarang burung walet.

+
+
+ + +
+
+ +
+
+
+
+

Total Pelanggan

+

148

+
+
+group +
+
+
+trending_up + +12% Bulan ini +
+
+
+
+
+

Pelanggan Aktif

+

124

+
+
+verified +
+
+
+ Berdasarkan transaksi 30 hari terakhir +
+
+
+
+
+

Wilayah Ekspor

+

12

+
+
+public +
+
+
+ Tiongkok, Vietnam, Singapura +
+
+
+
+
+

Piutang Berjalan

+

Rp 2.4M

+
+
+account_balance_wallet +
+
+
+warning + 3 Melewati tenggat +
+
+
+ +
+
+
+
+filter_list + +
+
+ Menampilkan 1-10 dari 148 +
+
+
+search + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KODENAMA PELANGGANKONTAK & EMAILINFORMASI BANKWILAYAHSTATUS
CUST-001 +
+Tianjin Trading Co., Ltd +Pusat Distribusi Asia Timur +
+
+
++86 138 9988 7766 +order@tianjintrading.cn +
+
+
+ICBC Bank +8823 **** 0092 +
+
+Internasional + +Aktif + + +
CUST-002 +
+PT Sinar Makmur Abadi +Distributor Lokal Jakarta +
+
+
+021-5556677 +logistic@sinarmakmur.co.id +
+
+
+Bank Central Asia +2210 **** 5541 +
+
+Domestik + +Aktif + + +
CUST-003 +
+Guangzhou Wellness Ltd +Importir Obat Tradisional +
+
+
++86 20 1234 5678 +import@gzwellness.com +
+
+
+Bank of China +4431 **** 8899 +
+
+Internasional + +Tertunda + + +
CUST-004 +
+Singapore Bird Nest Center +Toko Ritel & Ekspor +
+
+
++65 9123 4567 +contact@sgbirdnest.sg +
+
+
+DBS Bank +1109 **** 2231 +
+
+Internasional + +Aktif + + +
CUST-005 +
+UD Sarang Sejahtera +Pengepul Lokal Surabaya +
+
+
+031-778899 +admin@sarangsejahtera.com +
+
+
+Bank Mandiri +1420 **** 0021 +
+
+Domestik + +Non-Aktif + + +
+
+
+
+ Halaman 1 dari 15 +
+
+ + +
+
+
+
+
+ + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/daftar_pelanggan_2/screen.png b/design-assets/stitch_abel_stock/daftar_pelanggan_2/screen.png new file mode 100644 index 0000000..d91f421 Binary files /dev/null and b/design-assets/stitch_abel_stock/daftar_pelanggan_2/screen.png differ diff --git a/design-assets/stitch_abel_stock/daftar_pemasok_1/code.html b/design-assets/stitch_abel_stock/daftar_pemasok_1/code.html new file mode 100644 index 0000000..c72bd62 --- /dev/null +++ b/design-assets/stitch_abel_stock/daftar_pemasok_1/code.html @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + +
+
+Logo Perusahaan Walet +Sistem Inventaris Walet +
+
+ +
+ + + +
+
+
+ + + +
+
+ +
+
+

Daftar Pemasok

+

Kelola database pemasok sarang burung walet mentah dan mitra ekspor.

+
+
+ + +
+
+ +
+
+
+groups +
+
+

TOTAL PEMASOK

+

42

+
+
+
+
+verified +
+
+

PEMASOK TERVERIFIKASI

+

38

+
+
+
+
+local_shipping +
+
+

PENGIRIMAN BULAN INI

+

128

+
+
+
+
+trending_up +
+
+

TOTAL VOLUME (KG)

+

1,450.5

+
+
+
+ +
+ +
+
+
+ +expand_more +
+
+ +expand_more +
+
+

Menampilkan 1-10 dari 42 pemasok

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Kode PemasokNama PemasokInformasi KontakDetail BankStatusAksi
SUP-W-001 +
+
BT
+
+

UD Berkah Tayan

+

Kalimantan Barat

+
+
+
+

+62 812-3456-7890

+

berkahtayan@example.com

+
+
+BCA +

882-019-2831

+
+

A.n. Ahmad Sudrajat

+
+ + Aktif + + + +
SUP-W-002 +
+
MA
+
+

Mitra Abadi Medan

+

Sumatera Utara

+
+
+
+

+62 811-9988-7766

+

mitramedan@example.com

+
+
+BNI +

0932-1122-33

+
+

A.n. CV Mitra Abadi

+
+ + Aktif + + + +
SUP-W-003 +
+
SJ
+
+

Sumber Jaya Walet

+

Jawa Tengah

+
+
+
+

+62 813-0011-2233

+

sumberjaya@example.com

+
+
+Mandiri +

123-00-0987654-2

+
+

A.n. Lilik Wijaya

+
+ + Menunggu Verifikasi + + + +
SUP-W-004 +
+
PH
+
+

Putra Hulu Kapuas

+

Kalimantan Barat

+
+
+
+

+62 852-1111-2222

+

putrahulu@example.com

+
+
+BRI +

0021-01-223344-55-6

+
+

A.n. PT Putra Hulu Kapuas

+
+ + Aktif + + + +
+
+ +
+ +
+ + + +... + +
+ +
+
+
+
+ +
+ +
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/daftar_pemasok_1/screen.png b/design-assets/stitch_abel_stock/daftar_pemasok_1/screen.png new file mode 100644 index 0000000..207fd65 Binary files /dev/null and b/design-assets/stitch_abel_stock/daftar_pemasok_1/screen.png differ diff --git a/design-assets/stitch_abel_stock/daftar_pemasok_2/code.html b/design-assets/stitch_abel_stock/daftar_pemasok_2/code.html new file mode 100644 index 0000000..fa273dd --- /dev/null +++ b/design-assets/stitch_abel_stock/daftar_pemasok_2/code.html @@ -0,0 +1,419 @@ + + + + + + + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+
+ + +
+
+
+ +User Profile +
+
+
+ +
+ +
+
+

Daftar Pemasok

+ +
+ +
+ +
+
+

Total Pemasok

+
+124 + +trending_up + +3 bulan ini + +
+
+
+

Pemasok Aktif

+
+118 +
+
+
+
+
+
+

Menunggu Review QC

+
+6 +URGENT +
+
+
+

Total Transaksi (Bln)

+
+Rp 2.4M +payments +
+
+
+ +
+
+
+ + +
+
+ Menampilkan 1 - 10 dari 124 Pemasok +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KodeNama PemasokTeleponEmailInformasi BankStatusAksi
SUP-WAL-001CV. Sarang Abadi Jaya0812-3456-7890admin@sarangabadi.com +
Bank BCA
+
8820-XXXX-1123
+
+Aktif + + +
SUP-WAL-002PT. Walet Kalimantan Lestari0821-9988-7766info@waletkal.id +
Bank Mandiri
+
123-00-XXXX-456
+
+Aktif + + +
SUP-WAL-003UD. Sumber Alam0852-1122-3344sumberalam@mail.com +
Bank BNI
+
4455-XXXX-001
+
+Non-Aktif + + +
SUP-WAL-004Bapak Heru Setiawan (Personal)0813-5566-7788heru.s@gmail.com +
Bank BRI
+
0012-XXXX-998
+
+Aktif + + +
SUP-WAL-005Koperasi Walet Mandiri0811-0011-2233kop@waletmandiri.org +
Bank BCA
+
8820-XXXX-5567
+
+Aktif + + +
+
+ +
+
+ + + + +... + + +
+
+Baris per halaman: + +
+
+
+ +
+
+info +
+

Catatan Keamanan Data

+

Pastikan semua data rekening bank telah diverifikasi melalui proses KYC (Know Your Customer) sebelum melakukan pencairan dana untuk lot sarang burung walet yang diterima.

+
+
+
+
+
+contact_support +
+
+

Butuh Bantuan?

+

Hubungi IT Support

+
+
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/daftar_pemasok_2/screen.png b/design-assets/stitch_abel_stock/daftar_pemasok_2/screen.png new file mode 100644 index 0000000..bcf94be Binary files /dev/null and b/design-assets/stitch_abel_stock/daftar_pemasok_2/screen.png differ diff --git a/design-assets/stitch_abel_stock/dashboard/code.html b/design-assets/stitch_abel_stock/dashboard/code.html new file mode 100644 index 0000000..ed9a670 --- /dev/null +++ b/design-assets/stitch_abel_stock/dashboard/code.html @@ -0,0 +1,504 @@ + + + + + +SwiftLot Walet Inventory Dashboard + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+ + +
+
+
+

Admin User

+

Pimpinan Operasional

+
+Profil pengguna +
+
+
+ +
+ +
+
+

Ikhtisar Operasional

+

Status real-time inventaris Sarang Burung Walet.

+
+
+ + + +
+
+ +
+
+
+Total Stok Aktif +inventory_2 +
+
+1.248,5 +kg +
+
+trending_up ++4,2% dari minggu lalu +
+
+
+
+Nilai Inventaris +account_balance_wallet +
+
+$ +4,82M +
+
+Diperbarui 12m lalu +
+
+
+
+Pembelian (Bulan Ini) +shopping_bag +
+
+312,4 +kg +
+
+42 Transaksi +
+
+
+
+Penjualan (Bulan Ini) +sell +
+
+284,1 +kg +
+
+check_circle +Sesuai Target +
+
+
+
+Penyusutan (Bulan Ini) +trending_down +
+
+0,84 +% +
+
+warning +Di atas batas (0,5%) +
+
+
+ +
+ +
+
+

Tren Pembelian vs Penjualan

+ +
+
+ +
+
+
+
+
+MEI +
+
+
+
+
+
+JUN +
+
+
+
+
+
+JUL +
+
+
+
+
+
+AGS +
+
+
+
+
+
+SEP +
+
+
+
+
+
+OKT +
+
+
+
+
+Pembelian +
+
+
+Penjualan +
+
+
+ +
+
+
+

Rata-rata Margin

+show_chart +
+
+22,4% +
+

Efisiensi pemrosesan meningkat sebesar 1,2% periode ini melalui kontrol kelembaban yang dioptimalkan.

+
+
+

Distribusi Umur Lot

+
+
+
+0-30 Hari +64% +
+
+
+
+
+
+
+31-60 Hari +28% +
+
+
+
+
+
+
+60+ Hari +8% +
+
+
+
+
+
+
+
+
+ +
+ +
+
+

Peringatan Lot Kritis

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID LotStatusUmurKelembabanMasalah
#SW-2023-901 +Dalam QC +72 Hari12,5%Melebihi Batas Umur
#SW-2023-912 +Ditahan +45 Hari14,2%Kelembaban Tinggi
#SW-2023-885 +Dalam QC +68 Hari11,8%Butuh Audit
+
+ +
+
+
+

Mitra Utama

+
+
+
+ + +
+
+
+
+
BH
+
+

Bumi Hijau Group

+

12 Lot / 145kg

+
+
++$82rb +
+
+
+
KS
+
+

Kalimantan Sourcing

+

8 Lot / 112kg

+
+
++$64rb +
+
+
+
PJ
+
+

Prima Jaya Walet

+

6 Lot / 88kg

+
+
++$49rb +
+
+
+
AS
+
+

Agro Sejahtera

+

5 Lot / 76kg

+
+
++$38rb +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/dashboard/screen.png b/design-assets/stitch_abel_stock/dashboard/screen.png new file mode 100644 index 0000000..1cfc8f8 Binary files /dev/null and b/design-assets/stitch_abel_stock/dashboard/screen.png differ diff --git a/design-assets/stitch_abel_stock/dashboard_with_new_logo/code.html b/design-assets/stitch_abel_stock/dashboard_with_new_logo/code.html new file mode 100644 index 0000000..6f53ae5 --- /dev/null +++ b/design-assets/stitch_abel_stock/dashboard_with_new_logo/code.html @@ -0,0 +1,507 @@ + + + + + +Birds Nest Inventory Dashboard + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+ + +
+
+
+

Admin User

+

Pimpinan Operasional

+
+Profil pengguna +
+
+
+ +
+ +
+
+

Ikhtisar Operasional

+

Status real-time inventaris Birds Nest.

+
+
+ + + +
+
+ +
+
+
+Total Stok Aktif +inventory_2 +
+
+1.248,5 +kg +
+
+trending_up ++4,2% dari minggu lalu +
+
+
+
+Nilai Inventaris +account_balance_wallet +
+
+$ +4,82M +
+
+Diperbarui 12m lalu +
+
+
+
+Pembelian (Bulan Ini) +shopping_bag +
+
+312,4 +kg +
+
+42 Transaksi +
+
+
+
+Penjualan (Bulan Ini) +sell +
+
+284,1 +kg +
+
+check_circle +Sesuai Target +
+
+
+
+Penyusutan (Bulan Ini) +trending_down +
+
+0,84 +% +
+
+warning +Di atas batas (0,5%) +
+
+
+ +
+ +
+
+

Tren Pembelian vs Penjualan

+ +
+
+ +
+
+
+
+
+MEI +
+
+
+
+
+
+JUN +
+
+
+
+
+
+JUL +
+
+
+
+
+
+AGS +
+
+
+
+
+
+SEP +
+
+
+
+
+
+OKT +
+
+
+
+
+Pembelian +
+
+
+Penjualan +
+
+
+ +
+
+
+

Rata-rata Margin

+show_chart +
+
+22,4% +
+

Efisiensi pemrosesan meningkat sebesar 1,2% periode ini melalui kontrol kelembaban yang dioptimalkan.

+
+
+

Distribusi Umur Lot

+
+
+
+0-30 Hari +64% +
+
+
+
+
+
+
+31-60 Hari +28% +
+
+
+
+
+
+
+60+ Hari +8% +
+
+
+
+
+
+
+
+
+ +
+ +
+
+

Peringatan Lot Kritis

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID LotStatusUmurKelembabanMasalah
#SW-2023-901 +Dalam QC +72 Hari12,5%Melebihi Batas Umur
#SW-2023-912 +Ditahan +45 Hari14,2%Kelembaban Tinggi
#SW-2023-885 +Dalam QC +68 Hari11,8%Butuh Audit
+
+ +
+
+
+

Mitra Utama

+
+Watermark +
+
+
+
+ + +
+
+
+
+
BH
+
+

Bumi Hijau Group

+

12 Lot / 145kg

+
+
++$82rb +
+
+
+
KS
+
+

Kalimantan Sourcing

+

8 Lot / 112kg

+
+
++$64rb +
+
+
+
PJ
+
+

Prima Jaya Walet

+

6 Lot / 88kg

+
+
++$49rb +
+
+
+
AS
+
+

Agro Sejahtera

+

5 Lot / 76kg

+
+
++$38rb +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/dashboard_with_new_logo/screen.png b/design-assets/stitch_abel_stock/dashboard_with_new_logo/screen.png new file mode 100644 index 0000000..b006777 Binary files /dev/null and b/design-assets/stitch_abel_stock/dashboard_with_new_logo/screen.png differ diff --git a/design-assets/stitch_abel_stock/formulir_adjustment_stok/code.html b/design-assets/stitch_abel_stock/formulir_adjustment_stok/code.html new file mode 100644 index 0000000..ea0b886 --- /dev/null +++ b/design-assets/stitch_abel_stock/formulir_adjustment_stok/code.html @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + +
+
+Logo +Sarang Inventory Pro +
+
+
+search + +
+
+ + +
+ M +
+
+
+
+
+ + + +
+
+
+

Penyesuaian Stok (Adjustment)

+

Lakukan pembaruan kuantitas lot untuk keperluan audit atau koreksi operasional.

+
+
+schedule +TERAKHIR DIUPDATE: 12 OKT 2023 14:20 +
+
+
+ +
+ +
+
+
1
+

Pilih Lot

+
+
+
+qr_code_scanner +
+ + +
+ +
+
+GRADE +
+ +AAA Super White +
+
+
+QTY SAAT INI +45.20 kg +
+
+LOKASI +
+location_on +Rak B-04-A +
+
+
+
+ +
+
+
2
+

Detail Adjustment

+
+
+
+ +
+ + + +
+
+
+ + +
+
+ +
+ +
kg
+
+

Gunakan titik (.) untuk desimal

+
+
+ +
+Total Akhir: +45.20 kg +
+
+
+ + +
+
+
+
+ +
+ +
+
+

Ringkasan Lot

+
+
+STATUS LOT +Ready to Export +
+
+TGL MASUK +10 Sep 2023 +
+
+ORIGIN +Kalimantan Barat +
+
+
+inventory +
+ +
+
+info +

Panduan Operasional

+
+
    +
  • +• +Pastikan timbangan telah dikalibrasi sebelum input qty baru. +
  • +
  • +• +Setiap adjustment di atas 5kg memerlukan approval Manager. +
  • +
  • +• +Alasan 'Penyusutan' akan otomatis tercatat dalam laporan Waste bulanan. +
  • +
+
+ +
+ + +
+
+
+ +
+
+

Riwayat Adjustment Terakhir

+Lihat Semua Laporan +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
WAKTUID LOTTIPEJUMLAHOPERATOR
12 Okt, 10:15SN-A902 +Penyusutan +-0.45 kgBudi Santoso
11 Okt, 16:40SN-C110 +Penambahan ++1.20 kgRiana Putri
+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/formulir_adjustment_stok/screen.png b/design-assets/stitch_abel_stock/formulir_adjustment_stok/screen.png new file mode 100644 index 0000000..691e22c Binary files /dev/null and b/design-assets/stitch_abel_stock/formulir_adjustment_stok/screen.png differ diff --git a/design-assets/stitch_abel_stock/formulir_penjualan/code.html b/design-assets/stitch_abel_stock/formulir_penjualan/code.html new file mode 100644 index 0000000..d47094d --- /dev/null +++ b/design-assets/stitch_abel_stock/formulir_penjualan/code.html @@ -0,0 +1,373 @@ + + + + + + + + + + + + + + +
+
+Form Penjualan Baru +
+Draft #SL-20231105 +
+
+notifications +help_outline +
+User profile +
+
+
+
+
+
+
+

Informasi Penjualan

+

Lengkapi detail pesanan sebelum melakukan alokasi stok.

+
+
+ + +
+
+
+
+
+
+

+person + Pilih Pelanggan +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+

+list_alt + Tabel Detail Barang +

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
JENIS ITEMGRADEQTY DIBUTUHKANSATUANHARGA JUAL (IDR)SUBTOTAL
+ + + + + +kg + +775.000.000 +delete +
+ + + + + +kg + +300.000.000 +delete +
Grand Total1.075.000.000
+
+
+
+
+
+
+account_balance +
+
+

+payments + Info Bank Pelanggan +

+
+
+
+ +

Bank Central Asia (BCA)

+
+
+ +

8830 1928 440

+
+
+ +

PT GLOBAL EXPORT INDO

+
+
+verified +Verified Payment Channel +
+
+
+
+

+history + Ringkasan Lot +

+
+
+Total Berat (Netto) +75.00 kg +
+
+Estimasi Jumlah Lot +~12 Lots +
+
+
+PPN (11%) +118.250.000 +
+
+Total Tagihan +1.193.250.000 +
+
+
+
+
+info +

+ Pastikan stok fisik tersedia di warehouse sebelum melanjutkan ke tahap Alokasi Lot. Draft ini akan tersimpan secara otomatis. +

+
+
+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/formulir_penjualan/screen.png b/design-assets/stitch_abel_stock/formulir_penjualan/screen.png new file mode 100644 index 0000000..3eca339 Binary files /dev/null and b/design-assets/stitch_abel_stock/formulir_penjualan/screen.png differ diff --git a/design-assets/stitch_abel_stock/formulir_regrade/code.html b/design-assets/stitch_abel_stock/formulir_regrade/code.html new file mode 100644 index 0000000..db40a09 --- /dev/null +++ b/design-assets/stitch_abel_stock/formulir_regrade/code.html @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + +
+ +
+
+SwiftLot Walet +
+search + +
+
+
+
+notifications +help_outline +
+
+User profile +
+
+
+ +
+ + +
+ +
+
+
+

Regrade Lot

+ACTION REQUIRED +
+
+ +
+
+ +
+ +lock +
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+ +kg +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+

+analytics + Preview Perubahan Lot +

+
+
+ +
+ +
+
+

LOT ASAL (-)

+

LOT-2023-X9921

+
+-1.50 kg +
+
+Stok Awal: 12.45 kg +Estimasi Akhir: 10.95 kg +
+
+ +
+ +
+
+

LOT TUJUAN (+)

+

A2 - Standard Grade A

+
++1.50 kg +
+
+Stok Awal: 45.00 kg +Estimasi Akhir: 46.50 kg +
+
+
+
+ +
+
+info +
+

Informasi Kebijakan Regrade

+

+ Regrade lot akan mencatat histori perubahan pada kartu stok masing-masing grade. Pastikan tim QC telah melakukan validasi fisik sebelum menekan simpan. +

+
+
+
+ +
+Sorting Process +
+

STANDAR EKSPOR: GRADE AAA PREMIUM

+
+
+
+
+
+ +
+

© 2024 SWIFTLOT WALET SYSTEM • OPERATIONAL STACK V.2.1.0

+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/formulir_regrade/screen.png b/design-assets/stitch_abel_stock/formulir_regrade/screen.png new file mode 100644 index 0000000..48001be Binary files /dev/null and b/design-assets/stitch_abel_stock/formulir_regrade/screen.png differ diff --git a/design-assets/stitch_abel_stock/laporan_penelusuran_1/code.html b/design-assets/stitch_abel_stock/laporan_penelusuran_1/code.html new file mode 100644 index 0000000..3e105b4 --- /dev/null +++ b/design-assets/stitch_abel_stock/laporan_penelusuran_1/code.html @@ -0,0 +1,420 @@ + + + + + +Laporan Penelusuran - Sistem Inventaris Walet + + + + + + + + + +
+
+Logo +Sistem Inventaris Walet +
+
+ +
+ + + +Profil Pengguna +
+
+
+ + + +
+
+ +
+
+

Laporan Penelusuran (Traceability)

+

Lacak riwayat material dari asal pemasok hingga tujuan pengiriman.

+
+
+ + +
+
+ +
+
+
+ + + +
+
+
+ + + + Export Ready + +
+
+
+
+

TOTAL BERAT BERSIH

+

24.50 kg

+
+
+

KADAR AIR

+

8.2 %

+
+
+
+ +
+
+account_tree +

Diagram Alur Penelusuran

+
+
+ +
+ +
+
+agriculture +
+
+

SUMBER ASAL

+

PT. Walet Sejahtera

+

Kab. Kotawaringin

+
ID: SUP-KAL-01
+
+
+

Diterima: 12 Okt 2023

+
+
+ +
+
+warehouse +
+
+

PROSES BERJALAN

+

Gudang Utama (Pusat)

+

Sortir & Grading

+
LOT: 2023-WB0042
+
+
+

Diproses: 14 Okt 2023

+
+
+ +
+
+verified_user +
+
+

PENGECEKAN MUTU

+

Grade AAA - Putih

+

Lolos Uji Lab

+
Sertifikat: QC-8821
+
+
+

Disetujui: 16 Okt 2023

+
+
+ +
+
+local_shipping +
+
+

TUJUAN EKSPOR

+

Global Trade Corp

+

Hong Kong SAR

+
Rencana: 25 Okt 2023
+
+
+
+
+ +
+ +
+
+

Log Aktivitas Detail

+4 Riwayat Ditemukan +
+
+
+
+edit_document +
+
+
+

Dokumen Ekspor Disiapkan

+Hari ini, 09:12 +
+

Penerbitan Phytosanitary Certificate dan Packing List untuk Lot 2023-WB0042.

+

Oleh: Budi Santoso (Admin)

+
+
+
+
+fact_check +
+
+
+

Lolos Quality Control

+16 Okt 2023, 14:30 +
+

Hasil uji nitrit < 30ppm. Warna putih alami, tekstur mangkok sempurna.

+

Oleh: Dr. Siti Aminah (Lab)

+
+
+
+
+cleaning_services +
+
+
+

Tahap Pencucian & Pembersihan

+14 Okt 2023, 08:00 +
+

Pencucian manual menggunakan air RO. Pengeringan pada suhu 40°C.

+

Oleh: Tim Produksi A

+
+
+
+
+download_done +
+
+
+

Penerimaan Bahan Baku

+12 Okt 2023, 11:20 +
+

Diterima dari PT Walet Sejahtera. Berat kotor: 26.12 kg.

+

Oleh: Ahmad Fauzi (Logistik)

+
+
+
+
+ +
+
+

+info + Spesifikasi Teknis Lot +

+
+
+Grade Produk +Super AAA - Mangkok +
+
+Metode Pengeringan +Heat-Pump Dryers +
+
+Kandungan Nitrit +22 ppm (Aman) +
+
+Lokasi Penyimpanan +Cold Storage - Ruang 02 +
+
+ID Sertifikasi +GACC-ID-2023-01 +
+
+
+ +
+

+image + Dokumentasi Visual +

+
+
+ +
+

Bahan Baku

+
+
+
+ +
+

Proses QC

+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/laporan_penelusuran_1/screen.png b/design-assets/stitch_abel_stock/laporan_penelusuran_1/screen.png new file mode 100644 index 0000000..6ed3470 Binary files /dev/null and b/design-assets/stitch_abel_stock/laporan_penelusuran_1/screen.png differ diff --git a/design-assets/stitch_abel_stock/laporan_penelusuran_2/code.html b/design-assets/stitch_abel_stock/laporan_penelusuran_2/code.html new file mode 100644 index 0000000..35aa9cc --- /dev/null +++ b/design-assets/stitch_abel_stock/laporan_penelusuran_2/code.html @@ -0,0 +1,452 @@ + + + + + + + + + + + + + + + + +
+ +
+
+

SarangTrace ERP

+
+
+search + +
+
+
+ + +
+User Profile +
+
+
+ +
+ +
+
+
+

AUDIT & TRACEABILITY

+

Laporan Penelusuran (Traceability)

+
+
+ + +
+
+ +
+
+ +
+qr_code + +
+
+
+ + +
+
+ + +
+
+ +
+calendar_today + +
+
+
+
+ +
+ +
+
+
+STATUS SAAT INI +

LOT-2023-AX78

+
+SIAP EKSPOR +| Update 2 Jam yang lalu +
+
+inventory +
+
+

Detail Material

+
+
+Jenis +Mangkok Putih A +
+
+Total Berat (In) +25.40 kg +
+
+Total Berat (Out) +22.15 kg +
+
+Kadar Air +11% - 13% +
+
+Efisiensi +87.2% +
+
+
+
+ +
+
+
+

Alur Genealogi Lot

+
+ + Supplier + + + Proses + + + Keluar + +
+
+ +
+ +
+
+
+
+

LOT IN (Penerimaan Material)

+

Farm Mandiri - Sukabumi

+

Lot ID: FMS-23-001 • 25.40 kg • Grade Raw

+
+
+12 OKT 2023 +08:45 WIB +
+
+
+ +
+
+
+
+

PENCUCIAN & PEMBERSIHAN

+

Workstation A-04 (Anto S.)

+

Penyusutan: 1.2 kg • Status: Selesai

+ +
+
+info +Catatan QC: +
+ Tingkat kebersihan optimal, bulu ringan minim. Siap proses grading. +
+
+
+14 OKT 2023 +13:20 WIB +
+
+
+ +
+
+
+
+

REGRADING (Penyortiran Kualitas)

+

Lab Quality Control

+
+MANGKOK A: 18kg +SUDUT: 4.15kg +
+
+
+15 OKT 2023 +10:00 WIB +
+
+
+ +
+
+
+
+

LOT OUT (Pengiriman/Ekspor)

+

Global Bird's Nest Ltd (Hong Kong)

+

Invoice: INV/EXP/10/2023-99 • Ship via: Air Cargo

+
+
+20 OKT 2023 +15:45 WIB +
+
+
+
+
+
+
+ +
+
+
+

Daftar Transformasi Detail

+
+Tampilan Baris: + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID AktivitasOperatorTahapanInput (kg)Output (kg)Loss (%)Status QC
ACT-90234Budi SantosoPembersihan Bulu25.4024.204.72% +LULUS +
ACT-90288Siti AminahPengeringan24.2022.855.58% +LULUS +
ACT-90312Rian PratamaGrading Akhir22.8522.153.06% +DITINJAU +
+
+
+
+
+
+ +
+
+verified +
+
+

Validasi Audit Berhasil

+

Sertifikasi nomor SNI-8922/WLT-23 telah diverifikasi untuk rantai pasok lot ini.

+
+ +
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/laporan_penelusuran_2/screen.png b/design-assets/stitch_abel_stock/laporan_penelusuran_2/screen.png new file mode 100644 index 0000000..c63b057 Binary files /dev/null and b/design-assets/stitch_abel_stock/laporan_penelusuran_2/screen.png differ diff --git a/design-assets/stitch_abel_stock/laporan_stok_1/code.html b/design-assets/stitch_abel_stock/laporan_stok_1/code.html new file mode 100644 index 0000000..f638e53 --- /dev/null +++ b/design-assets/stitch_abel_stock/laporan_stok_1/code.html @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + +
+
+Logo Perusahaan Walet +Sistem Inventaris Walet +
+
+ +
+ + +
+ +
+Profil Pengguna +
+
+
+
+ + + +
+
+ +
+
+

Laporan Stok Terkini

+

Data inventaris real-time per 24 Mei 2024

+
+
+
+calendar_today +Bulan Ini +expand_more +
+ +
+
+ +
+ +
+
+payments +
+

TOTAL NILAI INVENTARIS

+

Rp 4.280.550.000

+
+ +trending_up +12.4% + +vs bulan lalu +
+
+ +
+

TOTAL BERAT (KG)

+
+

1,425.80

+kg +
+
+
+
+
+
+
+
+
+
+
+ +
+

TINGKAT SUSUT (AVG)

+
+

3.12

+% +
+
+
+
+
+
+Target < 3% +Alert! +
+
+
+
+
+ +
+
+

Analisis Susut per Grade

+info +
+
+
+ +
+
+
AAA
+
+

Super Premium

+

Moisture Content: 12%

+
+
+
+

1.8%

+

OPTIMAL

+
+
+ +
+
+
AA
+
+

Premium High

+

Moisture Content: 14%

+
+
+
+

2.5%

+

NORMAL

+
+
+ +
+
+
B
+
+

Standard Grade

+

Moisture Content: 18%

+
+
+
+

4.2%

+

HIGH RISK

+
+
+
+ +
+
+ +
+
+

Tren Arus Barang (30 Hari)

+
+
+
Masuk +
+
+
Keluar +
+
+
+
+
+Stock trend graph visualization +
+
+

Minggu Ini

+

+245 kg masuk

+

-112 kg keluar

+
+
+

+133 kg

+

Net Stock Growth

+
+
+
+
+
+
+ +
+
+
+warehouse +

Detail Stok per Gudang

+
+
+
+filter_list + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NAMA GUDANGLOKASITOTAL LOTBERAT NETTOGRADE DOMINANSTATUSAKSI
+
+
+Gudang Jakarta Utama +
+
Jakarta Timur, DKI Jakarta412682.45 kg +AAA (45%) + +AKTIF + + +
+
+
+Plant Surabaya - Room A +
+
Sidoarjo, Jawa Timur288415.10 kg +AA (32%) + +AKTIF + + +
+
+
+Logistics Medan +
+
Belawan, Sumatera Utara156328.25 kg +B (28%) + +TRANSIT + + +
+
+
+QC Processing Lab +
+
Tangerang, Banten450 kg +- + +STANDBY + + +
+
+
+

Menampilkan 4 dari 12 lokasi gudang

+
+ + + + + +
+
+
+
+
+ +
+ +
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/laporan_stok_1/screen.png b/design-assets/stitch_abel_stock/laporan_stok_1/screen.png new file mode 100644 index 0000000..cd10074 Binary files /dev/null and b/design-assets/stitch_abel_stock/laporan_stok_1/screen.png differ diff --git a/design-assets/stitch_abel_stock/laporan_stok_2/code.html b/design-assets/stitch_abel_stock/laporan_stok_2/code.html new file mode 100644 index 0000000..313f92b --- /dev/null +++ b/design-assets/stitch_abel_stock/laporan_stok_2/code.html @@ -0,0 +1,533 @@ + + + + + + + + + + + + + + + + +
+ +
+
+SarangTrace ERP +
+

Laporan Stok Inventaris

+
+
+ + +
+ JD +
+
+
+ +
+
+
+ +
+ +calendar_today +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+

TOTAL STOK (KG)

+

1,245.82

+
+
+ +trending_up + 4.2% + +vs bulan lalu +
+
+
+
+

TOTAL NILAI (IDR)

+

18.42M

+
+
+ +trending_up + 1.8% + +vs bulan lalu +
+
+
+
+

SUSUT (SHRINKAGE)

+

2.14%

+
+
+ +trending_up + 0.4% + +Target < 2% +
+
+
+
+

PERPUTARAN (TURNOVER)

+

4.5x

+
+
+ +check_circle + Sehat + +Rata-rata industri +
+
+
+ +
+ +
+
+

Tren Pergerakan Stok

+
+ + +
+
+
+
+ +
+
+
840kg
+
+
+
+
+
+
+
+
+
+
+
+
+
1,245kg
+
+
+ +
+JANFEBMARAPRMEIJUNJULAGUSEPOKTNOVDES +
+
+
+
+
+
+Stok Tersedia +
+
+
+Stok Terkunci (QC) +
+
+
+ +
+
+

Rincian Per Kategori

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KATEGORISTOK (KG)NILAI (JT)
+
+ +Grade SSS - Mangkok +
+
242.504,850.0
+
+ +Grade AA - Oval +
+
315.205,358.4
+
+ +Grade A - Segitiga +
+
198.152,972.2
+
+ +Bahan Baku (Raw) +
+
420.004,200.0
+
+ +Lainnya (Patahan) +
+
69.971,049.5
+
+
+ +
+
+
+ +
+
+
+

Detail Log Pergerakan Inventaris

+
+
+ +search +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LOT IDTANGGALTIPE TRANSAKSIKATEGORIBERAT (KG)KADAR AIRSTATUSAKSI
#LOT-2024-058115 Jan 2024, 09:12Masuk (Gudang Utama)Raw Material25.5012.5% + + TERSEDIA + + + +
#LOT-2024-057914 Jan 2024, 14:45Proses (Cleaning)Semi-Processed12.208.2% + + DALAM QC + + + +
#LOT-2024-057514 Jan 2024, 08:30Keluar (Ekspor)Finished Goods50.005.0% + + DIKIRIM + + + +
#LOT-2023-994212 Jan 2024, 16:20Adjustment (Susut)Raw Material-0.45- + + SUSUT + + + +
+
+
+Menampilkan 1-15 dari 1,248 transaksi +
+ + + + +... + + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/laporan_stok_2/screen.png b/design-assets/stitch_abel_stock/laporan_stok_2/screen.png new file mode 100644 index 0000000..35d3f1b Binary files /dev/null and b/design-assets/stitch_abel_stock/laporan_stok_2/screen.png differ diff --git a/design-assets/stitch_abel_stock/login_sistem_inventory/code.html b/design-assets/stitch_abel_stock/login_sistem_inventory/code.html new file mode 100644 index 0000000..0318987 --- /dev/null +++ b/design-assets/stitch_abel_stock/login_sistem_inventory/code.html @@ -0,0 +1,177 @@ + + + + + + + + + + + +
+ +
+
+Sarang Logo +
+
+

Selamat Datang Kembali

+

Masuk ke Operational Hub SarangWMS untuk mengelola lot inventaris Anda.

+
+
+
+ +
+person + +
+
+
+
+ +Lupa Password? +
+
+lock + + +
+
+
+ + +
+ +
+ +
+
+
+
v2.4.0-PRO
+
© 2024 SarangWMS Global
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/login_sistem_inventory/screen.png b/design-assets/stitch_abel_stock/login_sistem_inventory/screen.png new file mode 100644 index 0000000..bef5475 Binary files /dev/null and b/design-assets/stitch_abel_stock/login_sistem_inventory/screen.png differ diff --git a/design-assets/stitch_abel_stock/lot_based_inventory_system/DESIGN.md b/design-assets/stitch_abel_stock/lot_based_inventory_system/DESIGN.md new file mode 100644 index 0000000..88f9692 --- /dev/null +++ b/design-assets/stitch_abel_stock/lot_based_inventory_system/DESIGN.md @@ -0,0 +1,158 @@ +--- +name: Lot-Based Inventory System +colors: + surface: '#f8fafa' + surface-dim: '#d8dadb' + surface-bright: '#f8fafa' + surface-container-lowest: '#ffffff' + surface-container-low: '#f2f4f4' + surface-container: '#eceeee' + surface-container-high: '#e6e8e9' + surface-container-highest: '#e1e3e3' + on-surface: '#191c1d' + on-surface-variant: '#3f484a' + inverse-surface: '#2e3131' + inverse-on-surface: '#eff1f1' + outline: '#6f797a' + outline-variant: '#bfc8ca' + surface-tint: '#1d6871' + primary: '#00454c' + on-primary: '#ffffff' + primary-container: '#0d5e67' + on-primary-container: '#92d5df' + inverse-primary: '#8ed1db' + secondary: '#4e6073' + on-secondary: '#ffffff' + secondary-container: '#cfe2f9' + on-secondary-container: '#526478' + tertiary: '#60320f' + on-tertiary: '#ffffff' + tertiary-container: '#7c4824' + on-tertiary-container: '#ffbb91' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#aaeef8' + primary-fixed-dim: '#8ed1db' + on-primary-fixed: '#001f23' + on-primary-fixed-variant: '#004f57' + secondary-fixed: '#d1e4fb' + secondary-fixed-dim: '#b5c8df' + on-secondary-fixed: '#091d2e' + on-secondary-fixed-variant: '#36485b' + tertiary-fixed: '#ffdbc7' + tertiary-fixed-dim: '#feb78a' + on-tertiary-fixed: '#311300' + on-tertiary-fixed-variant: '#6b3a17' + background: '#f8fafa' + on-background: '#191c1d' + surface-variant: '#e1e3e3' +typography: + display-lot: + fontFamily: Inter + fontSize: 24px + fontWeight: '700' + lineHeight: 32px + letterSpacing: -0.02em + h1: + fontFamily: Inter + fontSize: 20px + fontWeight: '600' + lineHeight: 28px + h2: + fontFamily: Inter + fontSize: 16px + fontWeight: '600' + lineHeight: 24px + body-base: + fontFamily: Inter + fontSize: 14px + fontWeight: '400' + lineHeight: 20px + body-sm: + fontFamily: Inter + fontSize: 13px + fontWeight: '400' + lineHeight: 18px + table-data: + fontFamily: Inter + fontSize: 13px + fontWeight: '500' + lineHeight: 16px + label-caps: + fontFamily: Inter + fontSize: 11px + fontWeight: '700' + lineHeight: 16px + letterSpacing: 0.05em +rounded: + sm: 0.125rem + DEFAULT: 0.25rem + md: 0.375rem + lg: 0.5rem + xl: 0.75rem + full: 9999px +spacing: + container-margin: 24px + gutter: 16px + compact-padding: 8px + row-height-sm: 32px + row-height-md: 48px +--- + +## Brand & Style + +The design system is engineered for high-stakes operational environments where precision and speed are paramount. The visual language communicates reliability and institutional trust, specifically tailored for the bird’s nest (Sarang Burung Walet) export and processing industry. + +The aesthetic follows a **Corporate Modern** approach. It avoids unnecessary decoration, focusing instead on structural clarity and high information density. The interface feels like a high-performance tool—utilitarian, crisp, and robust. By utilizing a "Scan-First" philosophy, the design ensures that lot codes, weights, and processing stages are immediately identifiable within complex data environments. + +## Colors + +The palette is anchored by a sophisticated **Deep Teal**, chosen for its balance between professional authority and the natural, organic origins of the product. + +- **Primary Deep Teal:** Used for primary actions, active navigation states, and key branding elements. +- **Surface & Backgrounds:** A tiered system of whites and very light grays (Slate 50/White) creates a clean workspace that minimizes eye strain during long operational shifts. +- **Functional Status Colors:** High-saturation greens, oranges, and reds are reserved strictly for operational status (e.g., 'In Quality Control', 'Pending Export', 'Contaminated'). +- **Neutral Gray:** Specifically designated for 'Closed' or 'Archived' lots to visually de-emphasize completed data. + +## Typography + +This design system utilizes **Inter** for its exceptional legibility in data-heavy contexts. The typographic scale is condensed to prioritize content density without sacrificing readability. + +- **Data Optimization:** The `table-data` style uses a Medium weight (500) to ensure that alphanumeric lot codes are legible even at smaller sizes. +- **Emphasis:** A specialized `display-lot` style is used for header-level lot identification, ensuring the primary object of focus is never missed. +- **Labels:** Uppercase labels with slight letter spacing are used for table headers and form field captions to create a clear hierarchy between the UI structure and the user's data. + +## Layout & Spacing + +The layout employs a **Fluid Grid** system with fixed sidebars for navigation. To accommodate high information density, the spacing scale is built on a 4px baseline grid. + +- **Information Density:** Tables and forms utilize `compact-padding` to maximize the number of visible rows on a single screen, reducing the need for excessive scrolling. +- **Grid Structure:** A 12-column system is used for dashboard layouts, while inventory logs utilize a full-width fluid layout to maximize the horizontal space for multi-column data (e.g., Lot ID, Harvest Date, Grade, Weight, Moisture Content, Status). +- **Margins:** Generous `container-margin` ensures the interface feels professional and organized, preventing the dense data from feeling cluttered. + +## Elevation & Depth + +Hierarchy is established through **Tonal Layers** and **Low-Contrast Outlines** rather than aggressive shadows. + +- **Surface Levels:** The background uses `bg_primary`, while active work surfaces like cards and table containers use `bg_secondary`. +- **Borders:** Every container is defined by a 1px solid border in `border_subtle`. This provides a clear "box" for data without adding visual weight. +- **Shadows:** A single, consistent "Soft Drop" shadow (0px 2px 4px rgba(0,0,0,0.05)) is used exclusively for floating elements like dropdown menus and modals to lift them off the work surface. + +## Shapes + +The shape language is **Soft (0.25rem)**, reflecting a precise and geometric personality. + +- **Components:** Buttons, input fields, and checkboxes all use the base 4px (0.25rem) radius. +- **Badges:** Status badges use a slightly higher `rounded-lg` (0.5rem) to differentiate them as interactive or informational "pills" within the rigid rectangular grid of the data tables. +- **Selection States:** Focus states and active row selections should use sharp, clear colored borders (Primary) to eliminate any ambiguity regarding user focus. + +## Components + +- **Lot Status Badges:** Compact pills with light background tints and dark text of the corresponding status color (e.g., Light Green background with Dark Green text). +- **Data Tables:** High-density rows with subtle hover states (#F1F5F9). Headers must remain sticky during vertical scrolls. +- **Input Fields:** Inset borders with a 2px Teal focus ring. For numerical data like "Weight (kg)", include right-aligned unit labels within the field. +- **Primary Buttons:** Solid Teal backgrounds with white text. Secondary buttons use a Teal outline with a transparent background. +- **Inventory Cards:** Used for summary stats (e.g., "Total Stock", "Pending QC"). These feature a large numerical display and a small trend indicator. +- **Progress Steppers:** Horizontal indicators showing the lifecycle of a lot—from "Raw Material" to "Processed" to "Export Ready." \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/lot_detail/code.html b/design-assets/stitch_abel_stock/lot_detail/code.html new file mode 100644 index 0000000..b2f97db --- /dev/null +++ b/design-assets/stitch_abel_stock/lot_detail/code.html @@ -0,0 +1,439 @@ + + + + + +Detail Lot - SwiftLot Walet + + + + + + + + +
+
+SwiftLot Walet + +
+
+
+search + +
+notifications +help_outline +
+ JD +
+
+
+ + + +
+
+ +
+
+
+
+qr_code_2 +
+
+
+

LOT-WAL-2023-0892

+Aktif +
+

Dibuat pada 14 Okt 2023 oleh Tim Operasional A

+
+
+
+
+

Kualitas

+

Super A++

+
+
+

Jenis

+

Bentuk Mangkok

+
+
+

Jumlah Saat Ini

+

12.450 kg

+
+
+
+
+ +
+ +
+ +
+ +
+
+info +

Detail Administrasi

+
+
+
+

Pemasok

+
+
+person +
+
+

Borneo Harvest Co.

+

Akun BCA: **** 9012

+
+
+
+
+
+

Ref. Penerimaan

+ +link + RC-88192 + +
+
+

Gudang

+

Zona B-4 / Ruang Dingin

+
+
+
+
+ +
+
+analytics +

Audit Jumlah

+
+
+
+

Penerimaan Awal

+

15.000 kg

+
+
+

Stok Tersedia

+

12.450 kg

+
+
+

Dipesan (Penjualan)

+

2.000 kg

+
+
+

Rusak/Susut

+

0.550 kg

+
+
+
+
+Verifikasi berat terakhir: +Hari ini, 09:45 WIB +
+
+
+
+ +
+
+
+route +

Lini Masa Pergerakan & Pemrosesan

+
+ +
+
+ +
+
+check +
+
+
+

Alokasi Penjualan (LOT-RES-99)

+21 Okt, 14:20 +
+

2.000 kg dialokasikan untuk Pesanan #ORD-552. Dipesan di Zona B-4.

+
+
+
+
+check +
+
+
+

Perpindahan Gudang

+18 Okt, 09:12 +
+

Dipindahkan dari Ruang Pengeringan ke Ruang Dingin Zona B-4.

+
+
+
+
+check +
+
+
+

Penyortiran & Penentuan Kualitas Selesai

+15 Okt, 11:30 +
+

Lot dipecah dari Induk LOT-RAW-442. Disertifikasi sebagai Kualitas Super A++.

+
+
+
+
+login +
+
+
+

Penerimaan Awal

+14 Okt, 08:00 +
+

Bahan mentah diterima dari Borneo Harvest Co. Berat: 15.000 kg.

+
+
+
+
+
+ +
+ +
+

+bolt + Aksi Cepat +

+
+ + + + +
+
+ +
+
+account_tree +

Silsilah

+
+
+ +
+

Asal (Induk)

+
+
+subdirectory_arrow_right +LOT-RAW-442 +
+visibility +
+
+ +
+

Pecahan (Anak)

+
+
+
+chevron_right +LOT-SAL-8821 +
+Siap Ekspor +
+
+
+chevron_right +LOT-SAL-8822 +
+Menunggu QC +
+

Akhir Silsilah

+
+
+
+
+ +
+
+Kode QR +
+

Label Dibuat Sistem

+

ID: LW-23-0892

+
+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/lot_detail/screen.png b/design-assets/stitch_abel_stock/lot_detail/screen.png new file mode 100644 index 0000000..41753ae Binary files /dev/null and b/design-assets/stitch_abel_stock/lot_detail/screen.png differ diff --git a/design-assets/stitch_abel_stock/master_jenis_grade/code.html b/design-assets/stitch_abel_stock/master_jenis_grade/code.html new file mode 100644 index 0000000..d642cfd --- /dev/null +++ b/design-assets/stitch_abel_stock/master_jenis_grade/code.html @@ -0,0 +1,441 @@ + + + + + + + + + + + + + + + + +
+ +
+
+Sarang Inventory Pro +
+

Master Jenis & Grade

+
+
+
+notifications +settings +help_outline +
+
+
+

Admin Gudang

+

Superuser

+
+Manager Avatar +
+
+
+ +
+ +
+
+

Standardisasi Produk

+

Konfigurasi standar kualitas dan harga untuk operasional gudang.

+
+ +
+ +
+
+Total Jenis +
+12 ++2 bulan ini +
+
+
+Grade Aktif +
+4 +AAA, AA, A, B +
+
+
+Harga Standard (Rata-rata) +
+14.2M +IDR/Kg +
+
+
+Terakhir Update +
+history +Hari ini, 09:12 +
+
+
+ +
+
+
+search + +
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nama JenisGradeDeskripsi KualitasHarga Beli (IDR)Harga Jual (IDR)Status
+
+
+inventory_2 +
+
+

Mangkok Putih

+

ID: BRG-001

+
+
+
+AAA + +

Warna putih kristal, bentuk sempurna, kadar air < 12%

+
14,500,00018,200,000 +Aktif + + +
+
+
+inventory_2 +
+
+

Mangkok Putih

+

ID: BRG-002

+
+
+
+AA + +

Putih bersih, ada sedikit serabut, bentuk utuh

+
12,200,00015,800,000 +Aktif + + +
+
+
+inventory_2 +
+
+

Sudut/Patahan

+

ID: BRG-045

+
+
+
+B + +

Patahan besar, warna putih krem, bersih

+
8,500,00011,200,000 +Aktif + + +
+
+
+inventory_2 +
+
+

Plontos Putih

+

ID: BRG-009

+
+
+
+A + +

Warna putih, bentuk segitiga/setengah mangkok

+
10,500,00013,400,000 +Draft + + +
+
+ +
+Menampilkan 1-4 dari 12 data +
+ + + + + +
+
+
+ +
+
+

+info + Catatan Standardisasi +

+
    +
  • + + Harga standard direfresh setiap hari Senin pukul 08:00 WIB. +
  • +
  • + + Selisih harga jual-beli dipatok minimal 15% untuk operasional. +
  • +
  • + + QC wajib mengacu pada deskripsi kualitas di atas untuk grading Lot baru. +
  • +
+
+
+

+trending_up + Analisis Margin Terakhir +

+
+
+
+Mangkok Putih AAA +25.5% Margin +
+
+
+
+
+
+
+Sudut/Patahan B +31.7% Margin +
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/master_jenis_grade/screen.png b/design-assets/stitch_abel_stock/master_jenis_grade/screen.png new file mode 100644 index 0000000..79b8548 Binary files /dev/null and b/design-assets/stitch_abel_stock/master_jenis_grade/screen.png differ diff --git a/design-assets/stitch_abel_stock/pencarian_scan_qr/code.html b/design-assets/stitch_abel_stock/pencarian_scan_qr/code.html new file mode 100644 index 0000000..55f504b --- /dev/null +++ b/design-assets/stitch_abel_stock/pencarian_scan_qr/code.html @@ -0,0 +1,393 @@ + + + + + + + + + + + + + + +
+
+Sistem Inventaris Walet Logo +Sistem Inventaris Walet +
+
+ + + + +
+
+ + + +
+
+ +
+
+

Scan Lookup

+

Pencarian cepat status dan histori lot sarang burung walet.

+
+
+schedule + Terakhir diperbarui: 12 Okt 2023, 14:30 +
+
+ +
+ +
+
+
+

Input Lot ID

+camera_alt +
+ +
+
+
+
+
+
+
+
+qr_code_2 +
+

Arahkan kamera ke QR Code

+
+
+
+
+ +
+ + +
+
+ +
+
+ +
+

Statistik Pencarian

+
+
+Total Scan Hari Ini +142 Scan +
+
+
+
+
+Target harian: 200 lot +71% tercapai +
+
+
+
+ +
+ +
+
+
+

Lot Identifikasi

+

LOT-2023-XB99

+
+
+verified +Terverifikasi QC +
+
+ +
+
+

Status Saat Ini

+ + Siap Ekspor + +
+
+

Berat Bersih

+

2.450 kg

+
+
+

Kualitas / Grade

+

Super AAA

+
+
+

Kadar Air

+

12.5 %

+
+
+
+ +
+

Riwayat Perjalanan

+
+
+
+warehouse +
+

Gudang Penyimpanan Final

+

Seksi C - Rak 04 | 12 Okt 2023, 09:15

+
+
+
+fact_check +
+

Pengecekan Kualitas Akhir

+

QC Passed oleh Budi S. | 11 Okt 2023, 15:40

+
+
+
+dry_cleaning +
+

Tahap Pengeringan

+

Suhu 35°C - Kelembaban 40% | 10 Okt 2023

+
+
+
+inventory +
+

Penerimaan Bahan Baku

+

Dari Vendor: Lestari Alam | 08 Okt 2023

+
+
+
+ +
+

Lokasi Saat Ini

+
+Map of warehouse location +
+
+location_on +
+
+
+
+apartment +
+

Gudang Utama Jakarta

+

Jl. Industri Walet No. 8, Jakarta Utara

+
+
+
+
+ + +
+
+
+
+ +
+
+

Lot Terkait dalam Pengiriman

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Lot IDBeratGradeStatus
LOT-2023-XB971.200 kgSuper AAA +READY + + +
LOT-2023-XB980.850 kgGrade A +READY + + +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/pencarian_scan_qr/screen.png b/design-assets/stitch_abel_stock/pencarian_scan_qr/screen.png new file mode 100644 index 0000000..a9eefc2 Binary files /dev/null and b/design-assets/stitch_abel_stock/pencarian_scan_qr/screen.png differ diff --git a/design-assets/stitch_abel_stock/picking_gudang_mobile/code.html b/design-assets/stitch_abel_stock/picking_gudang_mobile/code.html new file mode 100644 index 0000000..ddab8dd --- /dev/null +++ b/design-assets/stitch_abel_stock/picking_gudang_mobile/code.html @@ -0,0 +1,260 @@ + + + + + +Sarang Ops - Layar Picking + + + + + + + + + + +
+
+arrow_back +
+SO-2023-0892 +Sales Order +
+
+
+help_outline +
+User profile +
+
+
+ +
+ +
+
+

Progres Pengambilan

+2/5 Item +
+
+
+
+
+40% Selesai +3 Tersisa +
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+Scan QR Code pada Lot +
+
+
+ +
+

DAFTAR ITEM TERJADWAL

+ +
+
AKTIF
+
+
+LOT-SW-23110 +GRADE AAA+ +
+
+LOKASI RAK +A1-22-B +
+
+
+
+Target Qty +5.00 kg +
+
+Diambil +
+ +kg +
+
+
+ +
+ +
+
+
+LOT-SW-23111 +GRADE AA +
+
+LOKASI RAK +B2-04-A +
+
+
+Target: 2.50 kg +Antre +
+
+ +
+
+
+
+done +
+
+LOT-SW-23098 +SELESAI (1.20 kg) +
+
+08:45 WIB +
+
+
+
+ + + +
+ +
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/picking_gudang_mobile/screen.png b/design-assets/stitch_abel_stock/picking_gudang_mobile/screen.png new file mode 100644 index 0000000..f22fc43 Binary files /dev/null and b/design-assets/stitch_abel_stock/picking_gudang_mobile/screen.png differ diff --git a/design-assets/stitch_abel_stock/purchase_form/code.html b/design-assets/stitch_abel_stock/purchase_form/code.html new file mode 100644 index 0000000..3b3d2ce --- /dev/null +++ b/design-assets/stitch_abel_stock/purchase_form/code.html @@ -0,0 +1,366 @@ + + + + + +Formulir Pembelian - SwiftLot Walet + + + + + + + + + + +
+ +
+
+SwiftLot Walet +
+

Formulir Pembelian Baru

+
+
+
+notifications + +
+help_outline +
+User profile +
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+account_balance +Standard Chartered +
+

Acc: 9920-xxxx-1122

+
+
+
+
+ +
+
+

Item Inventaris

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Jenis ItemKualitasJumlahSatuanHarga SatuanSubtotalStatusCatatan
+ + + + + + +kg + + +156,250.00 + + RAW_MAT + + + + +delete +
+ + + + + + +kg + + +34,850.00 + + PENDING_QC + + + + +delete +
+ +
+
+ +
+
+
+Total Baris +02 Entri +
+
+Total Berat +16.75 kg +
+
+
+Total Keseluruhan +USD 191,100.00 +
+
+
+ +
+ +
+ + +
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/purchase_form/screen.png b/design-assets/stitch_abel_stock/purchase_form/screen.png new file mode 100644 index 0000000..765fb4a Binary files /dev/null and b/design-assets/stitch_abel_stock/purchase_form/screen.png differ diff --git a/design-assets/stitch_abel_stock/receipt_form/code.html b/design-assets/stitch_abel_stock/receipt_form/code.html new file mode 100644 index 0000000..efbc5f7 --- /dev/null +++ b/design-assets/stitch_abel_stock/receipt_form/code.html @@ -0,0 +1,417 @@ + + + + + +Formulir Penerimaan - SwiftLot Walet + + + + + + + + +
+
+SwiftLot Walet +
+

Formulir Penerimaan Barang

+
+
+
+notifications +help_outline +
+
+User profile +
+
+
+ + + +
+
+ +
+ +
+
+

+edit_document + Informasi Dasar +

+DRAFT +
+
+
+ + +
+
+ +
+ +search +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+

+account_balance + Info Pembayaran Pemasok +

+
+
+ +
BCA International - Central Branch
+
+
+ +
8830-441-2291
+
+
+ +
PREMIUM WALET GROUP LTD
+
+
+
+
+ +
+
+

+list_alt + Item Baris Inventaris +

+
+
+DIPESAN: +1,250.00 kg +
+
+DISETUJUI: +1,180.00 kg +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DESKRIPSI ITEMDIPESANDITERIMADISETUJUIDITOLAKBIAYA SATUANGUDANGLOKASI
+
Grade 3A - Super White
+
LOT-REF: SW-001
+
500.00 kg + + + + + +$1,200.00 + + + +
+
Grade 2B - Light Yellow
+
LOT-REF: LY-004
+
750.00 kg + + + + + +$850.00 + + + +
+
+ +
+
+warning +Validasi: Item Ditolak + Disetujui Baris 1 (500) sama dengan Diterima (500). Sistem OK. +
+
+TOTAL ITEM: +2 Item +
+
+
+ +
+
+ + +
+
+
+
ESTIMASI LOT
+
12 Lot Baru
+
+ +
+
+
+
+ +
+
+
+
+
+
TREN KUALITAS
+
94.4% Hasil
+
+
+trending_up +
+
+
+
+
+
Rata-rata tingkat penerimaan dari pemasok ini kuartal ini.
+
+
+
+qr_code_2 +
+
+
PELABELAN OTOMATIS
+
12 Label Siap
+
+print + Antrian cetak terhubung +
+
+
+
+ +
+
TIP OPERASIONAL
+
Verifikasi Tingkat Kelembaban
+

Pastikan semua grade Super White berada dalam rentang kelembaban 12-14% sebelum finalisasi rak penyimpanan.

+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/receipt_form/screen.png b/design-assets/stitch_abel_stock/receipt_form/screen.png new file mode 100644 index 0000000..8086870 Binary files /dev/null and b/design-assets/stitch_abel_stock/receipt_form/screen.png differ diff --git a/design-assets/stitch_abel_stock/scan_lookup/code.html b/design-assets/stitch_abel_stock/scan_lookup/code.html new file mode 100644 index 0000000..4654507 --- /dev/null +++ b/design-assets/stitch_abel_stock/scan_lookup/code.html @@ -0,0 +1,350 @@ + + + + + + + + + + + + + + + + +
+
+SarangTrace ERP +
+
+ + +
+User Profile +
+
+
+ +
+
+ +
+
+

Scan Lookup

+

Pindai kode QR atau masukkan ID Lot secara manual untuk melihat detail operasional.

+
+
+STATUS: OPERASIONAL +
+
+ +
+ +
+
+Scanner View + +
+
+
+ +
+
+
+
+
+

Posisikan Kode di Tengah Bingkai

+
+ +
+ + +
+
+ +
+
+ +keyboard +
+ +
+
+ +
+ +
+
+Hasil Pemindaian Terakhir +TERDETEKSI +
+
+
+
+

SBW-LOT-4412

+

Kategori: Sarang Putih (Super)

+
+
+
STATUS LOT
+READY TO EXPORT +
+
+ +
+
+ +
+verified +AAA+ (Premium) +
+
+
+ +
+weight +12.450 kg +
+
+
+ +
+location_on +Sector B, Rack 42 +
+
+
+ +
+humidity_low +8.4% +
+
+
+ +
+ +
+
+
+
+check +
+
+check +
+
+check +
+
+
+
+BAHAN BAKU +SORTASI +PACKING +PENGIRIMAN +
+
+ +
+ + + +
+
+
+ +
+
+

Scan Terakhir

+Lihat Semua +
+
+
+
+barcode +
+
+
SBW-LOT-3990
+
2 menit yang lalu • Raw Material
+
+chevron_right +
+
+
+qr_code +
+
+
SBW-LOT-2281
+
15 menit yang lalu • Sorting
+
+chevron_right +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/scan_lookup/screen.png b/design-assets/stitch_abel_stock/scan_lookup/screen.png new file mode 100644 index 0000000..074c522 Binary files /dev/null and b/design-assets/stitch_abel_stock/scan_lookup/screen.png differ diff --git a/design-assets/stitch_abel_stock/sesi_sortasi/code.html b/design-assets/stitch_abel_stock/sesi_sortasi/code.html new file mode 100644 index 0000000..be908ee --- /dev/null +++ b/design-assets/stitch_abel_stock/sesi_sortasi/code.html @@ -0,0 +1,434 @@ + + + + + + + + + + + + + + + +
+ +
+
+

SwiftLot Walet

+
+
+navigate_next + Sesi Sortasi Baru +
+
+
+
+search + +
+ + +
+User profile +
+
+
+ +
+ +
+
+

Sesi Sortasi Baru

+

Pisahkan material lot sumber menjadi grade hasil akhir.

+
+
+
+

OPERATOR

+

Budi Santoso

+
+
+
+

ID SESI

+

#SORT-20231027-001

+
+
+
+ +
+ +
+

+input + Input Material +

+
+
+ +
+ +unfold_more +
+
+
+ +
+ +KG +
+
+
+info +

+ Pastikan lot sumber sudah melalui tahap QC awal. Berat input akan dikurangi langsung dari stok gudang raw material. +

+
+
+
+ +
+
+

+splitscreen + Hasil Pecahan (Output) +

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
JENIS ITEMGRADE HASILQTY (KG)CATATAN
+ + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + +
+
+ +
+
+ + +

Material tidak layak proses

+
+
+ + +

Dihitung otomatis (Loss 8.5%)

+
+
+TOTAL BALANCE +10.00 KG +
+
+
+
+ +
+
+
+analytics +
+
+

EFEKTIVITAS

+

91.5%

+
+
+
+
+trending_down +
+
+

SHRINKAGE RATE

+

8.50%

+
+
+
+
+category +
+
+

JUMLAH GRADE

+

3 Kelas

+
+
+
+
+timer +
+
+

DURASI SESI

+

00:42:15

+
+
+
+ +
+
+history +

Draft disimpan otomatis pada 14:20

+
+
+ + +
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/sesi_sortasi/screen.png b/design-assets/stitch_abel_stock/sesi_sortasi/screen.png new file mode 100644 index 0000000..d0c009f Binary files /dev/null and b/design-assets/stitch_abel_stock/sesi_sortasi/screen.png differ diff --git a/design-assets/stitch_abel_stock/stock_lot_list/code.html b/design-assets/stitch_abel_stock/stock_lot_list/code.html new file mode 100644 index 0000000..c06ef69 --- /dev/null +++ b/design-assets/stitch_abel_stock/stock_lot_list/code.html @@ -0,0 +1,493 @@ + + + + + + + + + + + + + + + +
+ +
+
+SwiftLot Walet +
+search + +
+
+
+ + +
+ +
+
+
+ +
+ +
+
+

Daftar Lot Stok Inventaris

+

Manajemen waktu nyata untuk bahan baku sarang burung dan barang jadi.

+
+
+
+
+inventory +
+
+

Total Stok

+

1,420 kg

+
+
+
+
+timer +
+
+

Menunggu QC

+

85 kg

+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Kode LotPemasokJenis ItemKualitasJumlah TersediaBiaya SatuanGudangUmurStatusAksi
LOT-202311-001Kalimantan Source ABowl (SGP)AAA24.50 kg$850.00 +
+Pengolahan Utama +Zona A-04 +
+
12h +Aktif + + +
LOT-202311-042Sumatra DirectCorner (SGP)AA12.15 kg$720.00 +
+Pengolahan Utama +Zona B-12 +
+
45h +Ditahan + + +
LOT-202310-156Java HighlandBrokenB0.00 kg$450.00 +
+Penyimpanan Arsip +Zona X-01 +
+
98h +Ditutup + + +
LOT-202311-088Sumatra DirectBowl (SGP)AAA45.00 kg$865.00 +
+Cold Storage B +Rak 02-A +
+
5h +Aktif + + +
LOT-202311-102Kalimantan Source AFinesC8.40 kg$320.00 +
+Pengolahan Utama +Zona A-10 +
+
2h +Aktif + + +
+
+ +
+

Menampilkan 1 - 5 dari 128 lot

+
+ + + + +... + + +
+
+
+ +
+
+

+pie_chart + Distribusi Berdasarkan Kualitas +

+
+
+
+Kualitas AAA +65% +
+
+
+
+
+
+
+Kualitas AA +25% +
+
+
+
+
+
+
+Kualitas A / Di Bawah +10% +
+
+
+
+
+
+
+
+
+

+precision_manufacturing + Efisiensi Pemrosesan +

+

Rata-rata waktu penyelesaian dari Bahan Baku ke Grade Ekspor telah meningkat sebesar 12% bulan ini.

+
+
+

4.2h

+

Rata-rata Waktu Tunggu

+
+
+
+

98.4%

+

Tingkat Kelulusan QC

+
+
+
+

2.8t

+

Kapasitas Bulanan

+
+
+
+ +
+ +
+
+
+
+
+ \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/stock_lot_list/screen.png b/design-assets/stitch_abel_stock/stock_lot_list/screen.png new file mode 100644 index 0000000..6bb9d17 Binary files /dev/null and b/design-assets/stitch_abel_stock/stock_lot_list/screen.png differ diff --git a/design-assets/stitch_abel_stock/transfer_antar_gudang/code.html b/design-assets/stitch_abel_stock/transfer_antar_gudang/code.html new file mode 100644 index 0000000..86b58d4 --- /dev/null +++ b/design-assets/stitch_abel_stock/transfer_antar_gudang/code.html @@ -0,0 +1,372 @@ + + + + + + + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+ + + +
+
+
+

Ahmad Manager

+

Warehouse Head

+
+Manager Avatar +
+
+
+ +
+ +
+
+ +

Transfer Lot Internal

+

Lakukan pemindahan stok antar gedung atau rak penyimpanan di dalam area operasional.

+
+
+ + +
+
+
+ +
+ +
+
+
+1 +

Pilih Lot Inventaris

+
+Wajib Diisi +
+
+
+ +
+qr_code_scanner + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+2 +

Detail Tujuan Transfer

+
+
+
+
+ + +
+
+ + +
+
+ +
+ +KILOGRAM +
+

*Maksimal transfer: 15.5 kg (stok tersedia)

+
+
+
+ +
+
+
+3 +

Administrasi & Tanggal

+
+
+
+
+ +
+Staff +Budi Pratama (W-Staff) +
+
+
+ +
+calendar_today + +
+
+
+ + +
+
+
+
+ +
+ +
+
+

Ringkasan Transfer

+
+
+Lot Terpilih +--- +
+
+Dari +Main Hub +
+
+Ke +--- +
+
+

Estimasi Berat Transfer

+

0.00 kg

+
+
+
+swap_horiz +
+ +
+

Panduan Keamanan

+
    +
  • +check_circle +

    Pastikan timbangan telah dikalibrasi sebelum melakukan transfer fisik.

    +
  • +
  • +check_circle +

    Gunakan sarung tangan steril saat menangani kotak lot sarang burung.

    +
  • +
  • +check_circle +

    Update label fisik pada kotak setelah dipindahkan ke rak baru.

    +
  • +
+
+ +
+Warehouse Map +
+Lihat Peta Lokasi +
+
+
+
+
+ +
+
+

Sarang Inventory Pro v2.4.0

+

© 2023 SarangWMS Logistics System

+
+
+
+ + + \ No newline at end of file diff --git a/design-assets/stitch_abel_stock/transfer_antar_gudang/screen.png b/design-assets/stitch_abel_stock/transfer_antar_gudang/screen.png new file mode 100644 index 0000000..8576af1 Binary files /dev/null and b/design-assets/stitch_abel_stock/transfer_antar_gudang/screen.png differ diff --git a/docs/codex-handoff-2026-05-10.md b/docs/codex-handoff-2026-05-10.md new file mode 100644 index 0000000..a384fa1 --- /dev/null +++ b/docs/codex-handoff-2026-05-10.md @@ -0,0 +1,208 @@ +# 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. diff --git a/docs/deploy-production.md b/docs/deploy-production.md new file mode 100644 index 0000000..ab1e6b1 --- /dev/null +++ b/docs/deploy-production.md @@ -0,0 +1,209 @@ +# Deploy Production + +Dokumen ini menyiapkan deploy production untuk: + +- domain `abelbirdnest.id` +- reverse proxy `nginx` +- aplikasi Next.js di port `3007` +- database `PostgreSQL` +- source code dari git `https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git` +- user service khusus `abelbirdnest` + +## 1. Persiapan Server + +Siapkan: + +- Node.js LTS +- npm +- PostgreSQL +- nginx +- certbot / SSL Let’s Encrypt + +Direktori contoh: + +```bash +/var/www/abelbirdnest-web +``` + +## 2. Buat User Khusus Aplikasi + +Jalankan sebagai `root` atau dengan `sudo`: + +```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 +``` + +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: + +```bash +sudo -u abelbirdnest -H bash +cd /var/www/abelbirdnest-web +git clone https://git.iptek.co/wirabasalamah/AbelBirdNest-Stock.git . +``` + +Kalau server butuh autentikasi git internal, siapkan credential sesuai kebijakan server Git Anda. + +## 4. Environment Production + +Salin `.env.production.example` menjadi `.env.production`, lalu isi nilainya. + +Yang wajib: + +```env +NODE_ENV=production +PORT=3007 +APP_URL=https://abelbirdnest.id +DATABASE_URL=postgresql://... +AUTH_SECRET=... +AUTH_BOOTSTRAP=false +SMTP_HOST=... +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER=... +SMTP_PASSWORD=... +SMTP_FROM=... +``` + +Catatan: + +- `AUTH_SECRET` harus random panjang. +- `AUTH_BOOTSTRAP=false` wajib untuk production. +- `APP_URL` harus domain production final. + +## 5. 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 + +## 6. Build Production + +```bash +npm run build +``` + +## 7. Jalankan App di Port 3007 + +Manual: + +```bash +PORT=3007 npm run start +``` + +Atau gunakan `systemd` dari: + +```bash +deploy/systemd/abelbirdnest-web.service +``` + +Contoh setup: + +```bash +sudo cp deploy/systemd/abelbirdnest-web.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable abelbirdnest-web +sudo systemctl start abelbirdnest-web +sudo systemctl status abelbirdnest-web +``` + +Autostart saat server restart terjadi karena service di-`enable`. + +Untuk verifikasi: + +```bash +sudo systemctl is-enabled abelbirdnest-web +``` + +## 8. Reverse Proxy Nginx + +Gunakan file: + +```bash +deploy/nginx/abelbirdnest.id.conf +``` + +Pasang: + +```bash +sudo cp deploy/nginx/abelbirdnest.id.conf /etc/nginx/sites-available/abelbirdnest.id.conf +sudo ln -s /etc/nginx/sites-available/abelbirdnest.id.conf /etc/nginx/sites-enabled/abelbirdnest.id.conf +sudo nginx -t +sudo systemctl reload nginx +``` + +## 9. Health Check + +Endpoint health: + +```bash +GET /api/v1/health +``` + +Contoh: + +```bash +curl https://abelbirdnest.id/api/v1/health +``` + +## 10. Update Deployment Berikutnya + +Jika aplikasi sudah live dan ada update dari git: + +```bash +cd /var/www/abelbirdnest-web +git pull origin main +npm install +npm run prisma:migrate:deploy +npm run build +sudo systemctl restart abelbirdnest-web +``` + +Jika branch utama nanti bukan `main`, sesuaikan perintah `git pull`. + +## 11. Checklist Go-Live + +- `AUTH_BOOTSTRAP=false` +- `AUTH_SECRET` sudah production-grade +- `APP_URL=https://abelbirdnest.id` +- SSL aktif +- database backup aktif +- `npm run build` lulus +- `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 + +## 12. 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`. diff --git a/docs/mobile-api-blueprint.md b/docs/mobile-api-blueprint.md new file mode 100644 index 0000000..265fe5d --- /dev/null +++ b/docs/mobile-api-blueprint.md @@ -0,0 +1,271 @@ +# Mobile API Blueprint + +Dokumen ini merangkum: +- endpoint mobile per role +- layar minimum yang dibutuhkan aplikasi mobile +- batas scope mobile yang sengaja dipertahankan agar tetap praktis + +## Prinsip + +- Mobile memakai auth yang sama dengan web. +- Login lewat `POST /api/v1/auth/login`, lalu kirim `Authorization: Bearer `. +- Semua endpoint mobile berada di prefix `/api/v1/mobile`. +- Mobile fokus ke operasi cepat, scan, input lapangan, monitoring, dan closing ringan. +- Fitur admin berat seperti `users`, `settings`, `audit-trail`, dan master data lengkap tetap web-only. + +## Role Mobile + +Role yang didukung di mobile: +- `WAREHOUSE` +- `QC` +- `SALES` +- `PURCHASING` +- `OWNER` + +Role yang tidak menjadi target mobile utama: +- `ADMIN` +- `SYSTEM_ADMIN` + +## Bootstrap Umum + +Semua role mobile memakai: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard?locale=id|en` + +`/mobile/bootstrap` mengembalikan: +- user session +- daftar modul yang boleh diakses role tersebut +- summary ringkas operasional +- grade aktif +- gudang aktif dan lokasi aktif +- master transformation mode + +## Matriks Endpoint Per Role + +### Warehouse + +Tujuan: +- scan lot +- buat receipt +- generate lot +- buat penyesuaian stok +- kirim / selesaikan washing + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/lots` +- `GET /api/v1/mobile/lots/:id` +- `GET /api/v1/mobile/lots/scan?code=...` +- `GET /api/v1/mobile/receipts/bootstrap` +- `GET /api/v1/mobile/receipts` +- `POST /api/v1/mobile/receipts` +- `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` +- `POST /api/v1/mobile/stock-adjustments` +- `GET /api/v1/mobile/washing/bootstrap` +- `GET /api/v1/mobile/washing` +- `POST /api/v1/mobile/washing` +- `PUT /api/v1/mobile/washing/:id` +- `POST /api/v1/mobile/washing/:id/complete` + +### QC + +Tujuan: +- scan lot +- lihat detail lineage lot +- ubah grade / mixing +- bantu washing completion +- buat penyesuaian stok terkait QC + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/lots` +- `GET /api/v1/mobile/lots/:id` +- `GET /api/v1/mobile/lots/scan?code=...` +- `GET /api/v1/mobile/washing/bootstrap` +- `GET /api/v1/mobile/washing` +- `PUT /api/v1/mobile/washing/:id` +- `POST /api/v1/mobile/washing/:id/complete` +- `GET /api/v1/mobile/lot-transformations` +- `POST /api/v1/mobile/lot-transformations` +- `GET /api/v1/mobile/lot-transformations/:id` +- `GET /api/v1/mobile/stock-adjustments/bootstrap` +- `GET /api/v1/mobile/stock-adjustments` +- `POST /api/v1/mobile/stock-adjustments` + +### Sales + +Tujuan: +- lihat stok jual +- buat penjualan reguler +- buat penjualan JIT +- buat titip jual +- tutup transaksi + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/lots` +- `GET /api/v1/mobile/lots/:id` +- `GET /api/v1/mobile/lots/scan?code=...` +- `GET /api/v1/mobile/sales-regular/bootstrap` +- `GET /api/v1/mobile/sales-regular` +- `POST /api/v1/mobile/sales-regular` +- `GET /api/v1/mobile/sales-regular/:id` +- `POST /api/v1/mobile/sales-regular/:id/close` +- `GET /api/v1/mobile/sales-jit/bootstrap` +- `GET /api/v1/mobile/sales-jit` +- `POST /api/v1/mobile/sales-jit` +- `GET /api/v1/mobile/sales-jit/:id` +- `POST /api/v1/mobile/sales-jit/:id/close` +- `GET /api/v1/mobile/consignments/bootstrap` +- `GET /api/v1/mobile/consignments` +- `POST /api/v1/mobile/consignments` +- `GET /api/v1/mobile/consignments/:id` +- `POST /api/v1/mobile/consignments/lines/:lineId/close` + +### Purchasing + +Tujuan: +- buat draft pembelian +- edit / submit pembelian +- buat permintaan dana +- monitor analisis dan realisasi pembelian + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/purchases` +- `POST /api/v1/mobile/purchases` +- `GET /api/v1/mobile/purchases/:id` +- `PUT /api/v1/mobile/purchases/:id` +- `POST /api/v1/mobile/purchases/:id/submit` +- `POST /api/v1/mobile/purchases/:id/cancel` +- `GET /api/v1/mobile/fund-requests/bootstrap` +- `GET /api/v1/mobile/fund-requests` +- `POST /api/v1/mobile/fund-requests` +- `GET /api/v1/mobile/purchase-analyses` +- `GET /api/v1/mobile/purchase-analyses/:purchaseId` +- `GET /api/v1/mobile/purchase-realizations` +- `GET /api/v1/mobile/purchase-realizations/:purchaseId` + +### Owner + +Tujuan: +- monitoring dashboard +- lihat analisis pembelian +- lihat realisasi pembelian +- lihat status transaksi penting + +Endpoint minimum: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/purchases` +- `GET /api/v1/mobile/purchases/:id` +- `GET /api/v1/mobile/fund-requests` +- `GET /api/v1/mobile/purchase-analyses` +- `GET /api/v1/mobile/purchase-analyses/:purchaseId` +- `GET /api/v1/mobile/purchase-realizations` +- `GET /api/v1/mobile/purchase-realizations/:purchaseId` +- `GET /api/v1/mobile/sales-regular` +- `GET /api/v1/mobile/sales-jit` +- `GET /api/v1/mobile/consignments` +- `GET /api/v1/mobile/washing` + +## Layar Minimum Per Role + +### Warehouse + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Scan lot +4. Detail lot +5. Receipt baru +6. Detail receipt + generate lot +7. Penyesuaian stok +8. Daftar washing +9. Buat washing +10. Selesaikan washing + +### QC + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Scan lot +4. Detail lot dan turunan lot +5. Daftar transformasi +6. Buat mixing / ubah grade +7. Daftar washing +8. Selesaikan washing +9. Penyesuaian stok QC + +### Sales + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Stok siap jual +4. Scan lot +5. Buat penjualan reguler +6. Detail dan tutup penjualan reguler +7. Buat penjualan JIT +8. Detail dan tutup penjualan JIT +9. Buat titip jual +10. Detail dan tutup item titip jual + +### Purchasing + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Daftar pembelian +4. Form draft pembelian +5. Detail pembelian +6. Submit pembelian +7. Daftar permintaan dana +8. Form permintaan dana +9. Daftar analisis pembelian +10. Daftar realisasi pembelian + +### Owner + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Daftar pembelian +4. Detail pembelian +5. Daftar analisis pembelian +6. Detail analisis pembelian +7. Daftar realisasi pembelian +8. Detail realisasi pembelian +9. Daftar transaksi keluar +10. Daftar washing + +## Scope Yang Sengaja Tidak Dibawa ke Mobile + +- manajemen user +- pengaturan sistem +- audit trail penuh +- master data lengkap +- office buyout +- print label / dokumen +- laporan web yang kompleks + +## Urutan Implementasi UI Mobile yang Disarankan + +1. Warehouse +2. QC +3. Sales +4. Purchasing +5. Owner + +Alasannya: +- Warehouse dan QC paling sering butuh scan dan aksi lapangan cepat +- Sales di urutan berikutnya karena butuh transaksi tapi tidak banyak input master +- Purchasing dan Owner lebih banyak monitoring dan persetujuan ringan diff --git a/docs/postman/abelbirdnest-mobile-api.postman_collection.json b/docs/postman/abelbirdnest-mobile-api.postman_collection.json new file mode 100644 index 0000000..5a6023f --- /dev/null +++ b/docs/postman/abelbirdnest-mobile-api.postman_collection.json @@ -0,0 +1,569 @@ +{ + "info": { + "name": "AbelBirdnest Mobile Operations API", + "description": "Collection Postman untuk integrasi mobile Warehouse, QC, Sales, Purchasing, dan Owner. Gunakan Login terlebih dahulu untuk mengisi {{sessionToken}}.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:3000/api/v1" }, + { "key": "sessionToken", "value": "" }, + { "key": "purchaseId", "value": "" }, + { "key": "receiptId", "value": "" }, + { "key": "lotId", "value": "" }, + { "key": "washingId", "value": "" }, + { "key": "regularSaleId", "value": "" }, + { "key": "jitSaleId", "value": "" }, + { "key": "consignmentId", "value": "" }, + { "key": "consignmentLineId", "value": "" }, + { "key": "purchaseAnalysisId", "value": "" }, + { "key": "purchaseRealizationId", "value": "" } + ], + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{sessionToken}}", "type": "string" } + ] + }, + "item": [ + { + "name": "1. Auth", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const token = json?.data?.session_token;", + " const role = json?.data?.user?.role;", + " if (token) pm.collectionVariables.set('sessionToken', token);", + " if (role) pm.collectionVariables.set('userRole', role);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"identity\": \"admin\",\n \"password\": \"admin123\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + } + }, + { + "name": "Session Me", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/auth/me", + "host": ["{{baseUrl}}"], + "path": ["auth", "me"] + } + } + } + ] + }, + { + "name": "2. Bootstrap & Dashboard", + "item": [ + { + "name": "Mobile Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "bootstrap"] + } + } + }, + { + "name": "Mobile Dashboard", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/dashboard?locale=id", + "host": ["{{baseUrl}}"], + "path": ["mobile", "dashboard"], + "query": [ + { "key": "locale", "value": "id" } + ] + } + } + } + ] + }, + { + "name": "3. Warehouse & QC", + "item": [ + { + "name": "Lot List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lots", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lots"] + } + } + }, + { + "name": "Lot Scan", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lots/scan?code=LOT-EXAMPLE-001", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lots", "scan"], + "query": [ + { "key": "code", "value": "LOT-EXAMPLE-001" } + ] + } + } + }, + { + "name": "Lot Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lots/{{lotId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lots", "{{lotId}}"] + } + } + }, + { + "name": "Receipt Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/receipts/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts", "bootstrap"] + } + } + }, + { + "name": "Receipt List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/receipts", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts"] + } + } + }, + { + "name": "Create Receipt", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"purchase_id\": \"{{purchaseId}}\",\n \"receipt_date\": \"2026-05-16\",\n \"notes\": \"Receipt dari mobile\",\n \"lines\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/receipts", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts"] + } + } + }, + { + "name": "Generate Lots From Receipt", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/mobile/receipts/{{receiptId}}/generate-lots", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts", "{{receiptId}}", "generate-lots"] + } + } + }, + { + "name": "Stock Adjustment Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/stock-adjustments/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "stock-adjustments", "bootstrap"] + } + } + }, + { + "name": "Create Stock Adjustment", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"lot_id\": \"{{lotId}}\",\n \"adjustment_reason_id\": \"\",\n \"adjustment_date\": \"2026-05-16\",\n \"qty_change\": -1,\n \"notes\": \"Mobile adjustment\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/stock-adjustments", + "host": ["{{baseUrl}}"], + "path": ["mobile", "stock-adjustments"] + } + } + }, + { + "name": "Washing Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/washing/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing", "bootstrap"] + } + } + }, + { + "name": "Washing List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/washing", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing"] + } + } + }, + { + "name": "Create Washing", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"lot_id\": \"{{lotId}}\",\n \"washing_place_id\": \"\",\n \"washing_cost\": 10000,\n \"duration_hours\": 24\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/washing", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing"] + } + } + }, + { + "name": "Complete Washing", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"after_qty\": 1,\n \"grade_id\": \"\",\n \"warehouse_id\": \"\",\n \"warehouse_location_id\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/washing/{{washingId}}/complete", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing", "{{washingId}}", "complete"] + } + } + }, + { + "name": "Transformation List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lot-transformations", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lot-transformations"] + } + } + }, + { + "name": "Create Lot Transformation", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"transformation_type\": \"MIX\",\n \"transformation_date\": \"2026-05-16\",\n \"remainder_mode\": \"KEEP_SOURCE_GRADE\",\n \"processing_loss_mode\": \"SHRINKAGE\",\n \"notes\": \"Mobile transformation\",\n \"inputs\": [\n {\n \"source_lot_code\": \"LOT-EXAMPLE-001\",\n \"qty_used\": 1\n }\n ],\n \"outputs\": [\n {\n \"grade_id\": \"\",\n \"warehouse_id\": \"\",\n \"warehouse_location_id\": \"\",\n \"qty_produced\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/lot-transformations", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lot-transformations"] + } + } + } + ] + }, + { + "name": "4. Sales", + "item": [ + { + "name": "Regular Sales Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular", "bootstrap"] + } + } + }, + { + "name": "Regular Sales List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular"] + } + } + }, + { + "name": "Regular Sale Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular/{{regularSaleId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular", "{{regularSaleId}}"] + } + } + }, + { + "name": "Close Regular Sale", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"close_date\": \"2026-05-16\",\n \"lines\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular/{{regularSaleId}}/close", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular", "{{regularSaleId}}", "close"] + } + } + }, + { + "name": "JIT Sales Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-jit/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-jit", "bootstrap"] + } + } + }, + { + "name": "JIT Sales List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-jit", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-jit"] + } + } + }, + { + "name": "Close JIT Sale", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"close_date\": \"2026-05-16\",\n \"lines\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/sales-jit/{{jitSaleId}}/close", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-jit", "{{jitSaleId}}", "close"] + } + } + }, + { + "name": "Consignments Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/consignments/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments", "bootstrap"] + } + } + }, + { + "name": "Consignments List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/consignments", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments"] + } + } + }, + { + "name": "Consignment Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/consignments/{{consignmentId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments", "{{consignmentId}}"] + } + } + }, + { + "name": "Close Consignment Line", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"close_date\": \"2026-05-16\",\n \"qty_sold\": 1,\n \"qty_returned\": 0,\n \"selling_price\": 100000,\n \"sales_commission\": 0\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/consignments/lines/{{consignmentLineId}}/close", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments", "lines", "{{consignmentLineId}}", "close"] + } + } + } + ] + }, + { + "name": "5. Purchasing & Owner", + "item": [ + { + "name": "Purchases List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchases", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchases"] + } + } + }, + { + "name": "Purchase Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchases/{{purchaseId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchases", "{{purchaseId}}"] + } + } + }, + { + "name": "Submit Purchase", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/mobile/purchases/{{purchaseId}}/submit", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchases", "{{purchaseId}}", "submit"] + } + } + }, + { + "name": "Fund Requests Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/fund-requests/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "fund-requests", "bootstrap"] + } + } + }, + { + "name": "Fund Requests List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/fund-requests", + "host": ["{{baseUrl}}"], + "path": ["mobile", "fund-requests"] + } + } + }, + { + "name": "Purchase Analyses", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-analyses", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-analyses"] + } + } + }, + { + "name": "Purchase Analysis Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-analyses/{{purchaseAnalysisId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-analyses", "{{purchaseAnalysisId}}"] + } + } + }, + { + "name": "Purchase Realizations", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-realizations", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-realizations"] + } + } + }, + { + "name": "Purchase Realization Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-realizations/{{purchaseRealizationId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-realizations", "{{purchaseRealizationId}}"] + } + } + } + ] + } + ] +} diff --git a/docs/postman/abelbirdnest-mobile-local.postman_environment.json b/docs/postman/abelbirdnest-mobile-local.postman_environment.json new file mode 100644 index 0000000..78ee425 --- /dev/null +++ b/docs/postman/abelbirdnest-mobile-local.postman_environment.json @@ -0,0 +1,93 @@ +{ + "id": "e5db42ce-7b3a-4d72-8df7-abelbirdnest-mobile-local", + "name": "AbelBirdnest Mobile Local", + "values": [ + { + "key": "baseUrl", + "value": "http://localhost:3000/api/v1", + "type": "text", + "enabled": true + }, + { + "key": "sessionToken", + "value": "", + "type": "text", + "enabled": true + }, + { + "key": "defaultRedirectTo", + "value": "", + "type": "text", + "enabled": true + }, + { + "key": "userRole", + "value": "", + "type": "text", + "enabled": true + }, + { + "key": "scanCode", + "value": "LOT-260501-MIX-001", + "type": "text", + "enabled": true + }, + { + "key": "lotId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "transformationId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "itemTypeId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "itemGradeId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "targetItemGradeId", + "value": "2", + "type": "text", + "enabled": true + }, + { + "key": "warehouseId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "warehouseLocationId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "sourceLotCode1", + "value": "LOT-260501-MIX-001", + "type": "text", + "enabled": true + }, + { + "key": "sourceLotCode2", + "value": "LOT-260501-MIX-002", + "type": "text", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-05-02T06:05:00+07:00", + "_postman_exported_using": "Codex GPT-5" +} diff --git a/docs/project-spec/purchase-analysis-mapping.md b/docs/project-spec/purchase-analysis-mapping.md new file mode 100644 index 0000000..ba1cba0 --- /dev/null +++ b/docs/project-spec/purchase-analysis-mapping.md @@ -0,0 +1,45 @@ +# Purchase Analysis Mapping + +Dokumen ini memetakan sheet analisis pembelian ke modul sistem. + +## Letak proses + +- `Purchases`: berat beli, kadar beli, harga referensi, modal barang awal. +- `Receipts`: berat masuk, kadar masuk, perbandingan beli vs masuk. +- `Lots / Sorting`: berat akhir, kadar akhir, rasio barang atas rata-rata. +- `Purchase Analysis`: biaya operasional, valuasi market, laba rugi total, laba rugi agen. + +## Mapping field + +| Field sheet | Sumber utama | Implementasi | +| --- | --- | --- | +| Berat beli | Purchase lines | `purchase_lines.qty_ordered` atau override di `purchase_analyses.weight_buy` | +| Berat masuk | Receipt lines | agregat `receipt_lines.qty_received` atau override di `purchase_analyses.weight_received` | +| Berat akhir | Lots / sorting | agregat `inventory_lots.original_qty` atau override di `purchase_analyses.weight_final` | +| Kadar beli | Purchase | rata-rata `purchase_lines.purchase_moisture_percent` atau override | +| Kadar masuk | Receipt | rata-rata `receipt_lines.moisture_percent` atau override | +| Kadar akhir | Lot final | rata-rata `inventory_lots.final_moisture_percent` atau override | +| Barang atas rata-rata | QC / sorting | rata-rata `inventory_lots.above_average_ratio_percent` atau override | +| Harga MK A/R | Purchasing / owner | `purchase_lines.market_reference_price` atau override | +| Operasional | Costing | `purchase_analysis_cost_entries` | +| Laba/rugi agen | Costing | `purchase_analyses.agent_profit_share_total` | + +## Rumus sistem + +- `berat_naik_percent = (berat_masuk - berat_beli) / berat_beli * 100` +- `susut_tambah = berat_akhir - berat_masuk` +- `modal_barang = sum(purchase_lines.subtotal)` +- `operasional = sum(cost_entries.amount)` +- `total_modal_beli = modal_barang + operasional` +- `modal_beli_per_kg = modal_barang / berat_beli` +- `modal_masuk_per_kg = modal_barang / berat_masuk` +- `modal_jual_per_kg = modal_barang / berat_akhir` +- `total_modal_mal = manual market valuation atau harga_mk_ar * berat_akhir` +- `total_laba_rugi = total_modal_mal - total_modal_beli` +- `laba_total_per_kg = (total_laba_rugi - laba_rugi_agen) / berat_akhir` +- `laba_agen_per_kg = laba_rugi_agen / berat_akhir` + +## Catatan + +- Istilah `TOTAL MODAL MAL` di sheet sumber belum sepenuhnya baku. Di sistem, field ini diwakili sebagai `market valuation total`, supaya tetap fleksibel. +- Semua angka hasil hitung tetap bisa dioverride lewat modul `Purchase Analysis` bila perusahaan ingin mengikuti angka manual owner. diff --git a/docs/project-spec/walet-alur-bisnis.html b/docs/project-spec/walet-alur-bisnis.html new file mode 100644 index 0000000..33d46f5 --- /dev/null +++ b/docs/project-spec/walet-alur-bisnis.html @@ -0,0 +1,401 @@ + + + + + Blueprint Alur Bisnis Sistem Inventory Sarang Burung Walet + + + +

Blueprint Alur Bisnis Sistem Inventory Sarang Burung Walet

+ +

Ringkasan

+

Dokumen ini merangkum alur bisnis untuk sistem inventory sarang burung walet dengan karakter operasional berikut:

+ +

Sistem ini bukan inventory biasa. Sistem ini adalah lot-based traceable inventory system untuk perdagangan sarang burung walet.

+ +

Tujuan Sistem

+ + +

Entitas Bisnis Utama

+ + +

Prinsip Dasar Desain Stok

+

1. Stock Summary

+

Ringkasan stok per:

+ +

Digunakan untuk dashboard dan operasional cepat.

+ +

2. Stock Lot

+

Detail stok per batch atau lot:

+ +

Digunakan untuk traceability dan costing.

+ +

3. Stock Movement Ledger

+

Semua mutasi stok dicatat permanen:

+ +

Ledger ini menjadi sumber audit utama.

+ +

Aktor Utama

+

Admin Purchasing

+ +

Admin Gudang

+ +

Tim Sortasi / QC

+ +

Admin Sales

+ +

Owner / Manajemen

+ + +

Alur Bisnis End-to-End

+

Tahap 1. Master Setup

+

Sebelum transaksi berjalan, sistem harus memiliki data master:

+ + +

Tahap 2. Pembelian

+

Admin membuat dokumen pembelian.

+

Informasi pada header pembelian:

+ +

Pada detail pembelian, satu pembelian bisa memiliki banyak item:

+ +

Kemungkinan kondisi pembelian:

+
    +
  1. item sudah jelas jenis dan gradenya
  2. +
  3. item masih campuran atau grade sementara
  4. +
+ +

Tahap 3. Penerimaan Barang

+

Saat barang datang:

+ +

Setiap lot yang tercipta menyimpan data:

+ + +

Tahap 4. Sortasi / Verifikasi / Reclassification

+

Jika barang datang masih campur atau perlu dicek ulang, dilakukan sesi sortasi.

+

Contoh:

+ +

Hasil sortasi:

+ + +

Tahap 5. Penyimpanan dan Mutasi Gudang

+

Lot yang aktif disimpan di gudang atau lokasi tertentu.

+

Aktivitas gudang yang didukung:

+ + +

Tahap 6. Penjualan

+

Saat customer melakukan pembelian:

+ +

Satu sales line tidak harus dipenuhi dari satu lot.

+

Contoh:

+ +

Karena itu sistem membutuhkan allocation detail per lot.

+ +

Tahap 7. Picking dan Pengeluaran Barang

+ + +

Tahap 8. Retur Penjualan

+ + +

Tahap 9. Retur Pembelian

+ + +

Tahap 10. Shrinkage, Damage, Regrade, dan Adjustment

+

Sistem harus mendukung kejadian berikut pada level lot:

+ + +

Tahap 11. Reporting dan Audit

+

Sistem harus dapat menjawab pertanyaan berikut:

+ + +

Aturan Costing

+

Prinsip costing yang direkomendasikan:

+ +

Contoh:

+ +

Maka total cost penjualan adalah penjumlahan biaya dari semua allocation.

+

Metode alokasi yang didukung:

+ +

Rekomendasi untuk MVP adalah Hybrid dengan default FIFO.

+ +

Aturan Traceability

+

Backward Trace

+ +

Forward Trace

+ +

Process Trace

+ + +

Aturan Barcode dan QR

+

Rekomendasi implementasi:

+ +

Contoh struktur kode:

+

SKU:

+ +

Lot:

+ +

Proses scan yang didukung:

+ + +

Ringkasan Kebutuhan Menu Aplikasi

+ + +

Ringkasan Layar Utama

+ + +

Kesimpulan

+

Sistem yang dibutuhkan adalah sistem inventory sarang burung walet berbasis lot atau batch dengan kemampuan:

+ +

Dengan fondasi ini, bisnis dapat mengontrol stok, menjaga audit trail, menghitung HPP secara akurat, dan memantau kualitas supplier serta profitabilitas penjualan.

+ + diff --git a/docs/project-spec/walet-alur-bisnis.md b/docs/project-spec/walet-alur-bisnis.md new file mode 100644 index 0000000..94017f8 --- /dev/null +++ b/docs/project-spec/walet-alur-bisnis.md @@ -0,0 +1,382 @@ +# Blueprint Alur Bisnis Sistem Inventory Sarang Burung Walet + +## Ringkasan +Dokumen ini merangkum alur bisnis untuk sistem inventory sarang burung walet dengan karakter operasional berikut: + +- proses dimulai dari pembelian +- satu pembelian bisa terdiri dari beberapa jenis barang +- setiap jenis bisa memiliki beberapa grade +- barang yang diterima bisa langsung terklasifikasi atau masih perlu sortasi internal +- stok disimpan berbasis lot atau batch +- satu penjualan dapat mengambil barang secara parsial dari beberapa lot berbeda +- seluruh pergerakan barang harus dapat ditelusuri untuk kebutuhan costing, traceability, audit, dan analisis penyusutan + +Sistem ini bukan inventory biasa. Sistem ini adalah lot-based traceable inventory system untuk perdagangan sarang burung walet. + +## Tujuan Sistem +Sistem dirancang untuk: + +- mencatat pembelian multi jenis dan multi grade +- mengelola penerimaan barang dan pembentukan batch atau lot +- mendukung sortasi, verifikasi ulang, dan regrade +- menyimpan stok per lot sekaligus menampilkan ringkasan stok per jenis-grade +- mendukung penjualan campuran dari beberapa lot +- menghitung HPP berdasarkan lot yang benar-benar dipakai +- mencatat susut, rusak, reject, dan adjustment per lot +- menyediakan traceability penuh dari supplier ke customer dan sebaliknya +- mendukung barcode atau QR untuk scan operasional + +## Entitas Bisnis Utama +Sistem memiliki entitas bisnis utama berikut: + +- Supplier +- Customer +- Jenis Sarang +- Grade +- Gudang dan Lokasi Gudang +- Pembelian +- Penerimaan +- Lot Inventory +- Sortasi atau Reclassification +- Penjualan +- Sales Allocation +- Inventory Movement Ledger +- Stock Adjustment +- Return +- Barcode atau QR Label + +## Prinsip Dasar Desain Stok +Sistem menggunakan tiga lapisan stok. + +### 1. Stock Summary +Ringkasan stok per: +- jenis +- grade +- gudang + +Digunakan untuk dashboard dan operasional cepat. + +### 2. Stock Lot +Detail stok per batch atau lot: +- kode lot +- supplier +- jenis +- grade +- qty awal +- qty sisa +- cost +- tanggal masuk +- parent lot jika hasil sortasi + +Digunakan untuk traceability dan costing. + +### 3. Stock Movement Ledger +Semua mutasi stok dicatat permanen: +- receiving +- sorting +- regrade +- transfer +- sales allocation +- shrinkage +- adjustment +- return + +Ledger ini menjadi sumber audit utama. + +## Aktor Utama +Aktor yang akan berinteraksi dengan sistem: + +### Admin Purchasing +- membuat pembelian +- mengelola supplier +- melihat histori harga beli + +### Admin Gudang +- menerima barang +- membuat lot +- mengelola stok dan mutasi +- melakukan opname dan adjustment + +### Tim Sortasi / QC +- melakukan klasifikasi +- memecah lot +- menginput susut atau reject +- melakukan regrade jika diperlukan + +### Admin Sales +- membuat sales order +- mengalokasikan stok dari lot +- memproses picking dan invoice + +### Owner / Manajemen +- memantau stok +- memantau margin +- melihat penyusutan +- melihat traceability dan performa supplier + +## Alur Bisnis End-to-End + +### Tahap 1. Master Setup +Sebelum transaksi berjalan, sistem harus memiliki data master: +- supplier +- customer +- jenis sarang +- grade +- gudang +- lokasi gudang +- satuan +- user dan role +- reason code adjustment dan shrinkage +- allocation policy +- costing policy + +### Tahap 2. Pembelian +Admin membuat dokumen pembelian. + +Informasi pada header pembelian: +- nomor pembelian +- supplier +- tanggal pembelian +- referensi invoice supplier +- status +- catatan + +Pada detail pembelian, satu pembelian bisa memiliki banyak item: +- jenis +- grade, jika sudah diketahui +- qty atau berat +- harga beli +- subtotal +- status klasifikasi + +Kemungkinan kondisi pembelian: +1. item sudah jelas jenis dan gradenya +2. item masih campuran atau grade sementara + +### Tahap 3. Penerimaan Barang +Saat barang datang: +- sistem memverifikasi pembelian +- barang ditimbang +- kualitas awal dicek +- selisih dicatat +- lot inventory dibuat + +Setiap lot yang tercipta menyimpan data: +- kode lot +- supplier asal +- referensi pembelian +- jenis +- grade +- qty diterima +- qty tersedia +- cost per unit +- tanggal masuk +- gudang dan lokasi +- status lot +- nilai barcode atau QR + +Jika satu pembelian terdiri dari banyak item, maka bisa terbentuk banyak lot. + +### Tahap 4. Sortasi / Verifikasi / Reclassification +Jika barang datang masih campur atau perlu dicek ulang, dilakukan sesi sortasi. + +Contoh: +- lot masuk 50 kg +- hasil sortasi: + - Jenis A Grade A = 20 kg + - Jenis A Grade B = 15 kg + - Jenis B Grade A = 10 kg + - Reject atau susut = 5 kg + +Hasil sortasi: +- lot sumber dikurangi atau ditutup +- child lot baru dibuat +- susut dicatat +- hubungan parent-child tersimpan + +Dengan desain ini, sistem tetap tahu bahwa lot hasil sortasi berasal dari lot mana. + +### Tahap 5. Penyimpanan dan Mutasi Gudang +Lot yang aktif disimpan di gudang atau lokasi tertentu. + +Aktivitas gudang yang didukung: +- pindah lokasi rak +- transfer antar gudang +- hold atau release lot +- stock opname +- adjustment stok + +Semua aktivitas ini masuk ke movement ledger. + +### Tahap 6. Penjualan +Saat customer melakukan pembelian: +- admin sales membuat sales order +- item dipilih berdasarkan jenis dan grade +- qty dimasukkan +- sistem menampilkan stok yang tersedia + +Satu sales line tidak harus dipenuhi dari satu lot. + +Contoh: +Customer membeli Jenis A Grade A sebanyak 30 kg. +Alokasi bisa menjadi: +- 20 kg dari lot Supplier A +- 10 kg dari lot Supplier B + +Karena itu sistem membutuhkan allocation detail per lot. + +### Tahap 7. Picking dan Pengeluaran Barang +Setelah lot dialokasikan: +- petugas scan QR atau barcode lot +- qty yang benar-benar diambil dikonfirmasi +- bila ada selisih timbang, selisih dicatat +- stok lot berkurang sesuai qty realisasi + +### Tahap 8. Retur Penjualan +Jika barang dikembalikan customer: +- retur direferensikan ke penjualan +- jika memungkinkan, dikembalikan ke lot asal +- jika tidak, dibuat lot retur terpisah +- kondisi barang retur dicatat +- barang retur dapat dijual lagi, diregrade, atau direject + +### Tahap 9. Retur Pembelian +Jika ada masalah dengan supplier: +- barang diretur ke supplier +- sistem mengurangi lot asal +- nilai transaksi pembelian dapat dikoreksi + +### Tahap 10. Shrinkage, Damage, Regrade, dan Adjustment +Sistem harus mendukung kejadian berikut pada level lot: +- susut timbang +- kerusakan +- kehilangan +- reject +- perubahan grade +- koreksi hasil stock opname + +Semua perubahan ini harus masuk ke ledger agar histori tetap utuh. + +### Tahap 11. Reporting dan Audit +Sistem harus dapat menjawab pertanyaan berikut: +- stok tersedia berapa per jenis-grade +- lot mana saja yang aktif +- lot tertentu berasal dari supplier siapa +- penjualan tertentu mengambil lot mana saja +- supplier tertentu telah menjual barangnya ke customer mana saja +- berapa susut per lot dan per supplier +- berapa margin per penjualan, per jenis, dan per grade + +## Aturan Costing +Prinsip costing yang direkomendasikan: +- cost disimpan di level lot +- sales line dihitung berdasarkan allocation nyata ke lot + +Contoh: +- Lot A: 20 kg x 18 juta +- Lot B: 10 kg x 19 juta + +Maka total cost penjualan adalah penjumlahan biaya dari semua allocation. + +### Metode alokasi yang didukung +- FIFO +- FEFO jika dibutuhkan +- Manual allocation +- Hybrid, sistem memberi saran dan user bisa override + +Rekomendasi untuk MVP adalah Hybrid dengan default FIFO. + +## Aturan Traceability +Sistem wajib mendukung dua arah trace. + +### Backward Trace +Dari penjualan ke: +- lot +- receipt +- purchase +- supplier + +### Forward Trace +Dari supplier atau lot ke: +- sales allocation +- customer + +### Process Trace +Dari lot tertentu harus terlihat: +- asal pembelian +- hasil sortasi +- perubahan grade +- susut +- perpindahan gudang +- histori penjualan + +## Aturan Barcode dan QR +Rekomendasi implementasi: +- SKU code untuk jenis-grade +- lot code untuk identitas lot +- QR code untuk scan operasional + +### Contoh struktur kode +SKU: +- MANGKOK-A +- MANGKOK-B +- SUDUT-A + +Lot: +- LOT-260428-SPA-001 +- LOT-260428-SPB-002 +- LOT-260428-SPA-001-S1 untuk hasil sortasi + +QR dapat menyimpan lot_code atau token unik. Aplikasi akan mengambil detail dari database saat discan. + +### Proses scan yang didukung +- receiving +- sorting +- transfer gudang +- stock opname +- sales picking +- trace lookup + +## Ringkasan Kebutuhan Menu Aplikasi +Modul yang harus tersedia minimal: +- Dashboard +- Master Data +- Purchasing +- Receiving +- Sorting / Classification +- Inventory +- Sales +- Return +- Reports +- Barcode / QR +- Settings + +## Ringkasan Layar Utama +Layar yang disarankan: +- Dashboard +- Purchase List +- Purchase Form +- Receipt Form +- Sorting Session Form +- Stock Summary +- Stock Lot List +- Lot Detail +- Sales Form +- Allocation Screen +- Picking Screen +- Adjustment Form +- Regrade Form +- Barcode Lookup +- Reports + +## Kesimpulan +Sistem yang dibutuhkan adalah sistem inventory sarang burung walet berbasis lot atau batch dengan kemampuan: +- pembelian multi jenis dan multi grade +- sortasi dan reclassification +- partial sales dari banyak lot +- costing berdasarkan allocation nyata +- penyusutan per lot +- traceability dua arah +- barcode atau QR untuk scan operasional + +Dengan fondasi ini, bisnis dapat mengontrol stok, menjaga audit trail, menghitung HPP secara akurat, dan memantau kualitas supplier serta profitabilitas penjualan. \ No newline at end of file diff --git a/docs/project-spec/walet-alur-bisnis.pdf b/docs/project-spec/walet-alur-bisnis.pdf new file mode 100644 index 0000000..c7ac9ec Binary files /dev/null and b/docs/project-spec/walet-alur-bisnis.pdf differ diff --git a/docs/project-spec/walet-api-spec.md b/docs/project-spec/walet-api-spec.md new file mode 100644 index 0000000..5cd5f0c --- /dev/null +++ b/docs/project-spec/walet-api-spec.md @@ -0,0 +1,409 @@ +# API Spec Backend Sistem Inventory Walet + +## 1. Prinsip API +API berbasis REST JSON dengan fokus pada: +- lot-based inventory +- traceability +- partial allocation +- shrinkage and adjustment +- barcode/QR lookup + +Base path contoh: +`/api/v1` + +## 2. Auth +### POST /auth/login +Request: +```json +{ + "email": "admin@example.com", + "password": "secret" +} +``` +Response: +```json +{ + "token": "jwt-token", + "user": { + "id": 1, + "name": "Admin", + "role": "ADMIN" + } +} +``` + +--- + +## 3. Master Data +### GET /suppliers +### POST /suppliers +Field penting supplier: +- code +- name +- phone +- email +- bank_name +- bank_account_number +- address + +### GET /suppliers/{id} +### PUT /suppliers/{id} +### DELETE /suppliers/{id} + +### GET /customers +### POST /customers +Field penting customer: +- code +- name +- phone +- email +- bank_name +- bank_account_number +- address +### GET /item-types +### POST /item-types +### GET /item-grades +### POST /item-grades +### GET /warehouses +### POST /warehouses +### GET /warehouse-locations +### POST /warehouse-locations +### GET /adjustment-reasons +### POST /adjustment-reasons + +--- + +## 4. Purchasing +### GET /purchases +Filter: +- supplier_id +- status +- date_from +- date_to + +### POST /purchases +```json +{ + "supplier_id": 1, + "purchase_date": "2026-04-28", + "supplier_invoice_no": "INV-8891", + "notes": "Pembelian campuran", + "lines": [ + { + "item_type_id": 1, + "item_grade_id": 1, + "qty_ordered": 50, + "unit_id": 1, + "unit_price": 18000000, + "classification_status": "FINAL" + }, + { + "item_type_id": 1, + "item_grade_id": null, + "qty_ordered": 40, + "unit_id": 1, + "unit_price": 17500000, + "classification_status": "PROVISIONAL" + } + ] +} +``` + +### GET /purchases/{id} +### PUT /purchases/{id} +### POST /purchases/{id}/submit +### POST /purchases/{id}/cancel + +--- + +## 5. Receiving +### GET /receipts +### POST /receipts +```json +{ + "purchase_id": 1, + "supplier_id": 1, + "receipt_date": "2026-04-28", + "notes": "Barang diterima baik", + "lines": [ + { + "purchase_line_id": 1, + "item_type_id": 1, + "item_grade_id": 1, + "qty_received": 50, + "qty_accepted": 50, + "qty_rejected": 0, + "unit_id": 1, + "unit_cost": 18000000, + "warehouse_id": 1, + "warehouse_location_id": 1 + } + ] +} +``` + +### GET /receipts/{id} +### POST /receipts/{id}/generate-lots +Response contoh: +```json +{ + "receipt_id": 1, + "lots": [ + { + "id": 10, + "lot_code": "LOT-260428-SPA-001", + "qr_code_value": "LOT-260428-SPA-001" + } + ] +} +``` + +--- + +## 6. Inventory Lots +### GET /lots +Filter: +- supplier_id +- item_type_id +- item_grade_id +- warehouse_id +- status +- keyword + +### GET /lots/{id} +### GET /lots/{id}/movements +### GET /lots/{id}/trace +### POST /lots/{id}/hold +### POST /lots/{id}/release +### POST /lots/{id}/transfer +```json +{ + "to_warehouse_id": 2, + "to_location_id": 5, + "qty": 10, + "notes": "Pindah ke gudang cabang" +} +``` + +--- + +## 7. Sorting / Regrade +### POST /sorting-sessions +```json +{ + "source_lot_id": 10, + "sorting_date": "2026-04-28T15:00:00Z", + "input_qty": 40, + "shrinkage_qty": 3, + "notes": "Sortasi batch campuran", + "results": [ + { + "item_type_id": 1, + "item_grade_id": 1, + "qty_result": 18, + "unit_cost": 17500000 + }, + { + "item_type_id": 1, + "item_grade_id": 2, + "qty_result": 12, + "unit_cost": 17500000 + }, + { + "item_type_id": 2, + "item_grade_id": 1, + "qty_result": 7, + "unit_cost": 17500000 + } + ] +} +``` + +### GET /sorting-sessions +### GET /sorting-sessions/{id} + +### POST /lots/{id}/regrade +```json +{ + "target_grade_id": 2, + "qty": 5, + "reason_id": 3, + "notes": "Turun grade setelah QC" +} +``` + +--- + +## 8. Sales +### GET /sales +### POST /sales +```json +{ + "customer_id": 1, + "sales_date": "2026-04-28", + "notes": "Order customer X", + "lines": [ + { + "item_type_id": 1, + "item_grade_id": 1, + "qty_sold": 30, + "unit_id": 1, + "selling_price": 22000000 + } + ] +} +``` + +### GET /sales/{id} +### POST /sales/{id}/allocate +```json +{ + "lines": [ + { + "sales_line_id": 1, + "allocations": [ + { + "inventory_lot_id": 10, + "qty_allocated": 20 + }, + { + "inventory_lot_id": 11, + "qty_allocated": 10 + } + ] + } + ] +} +``` + +### POST /sales/{id}/auto-allocate +```json +{ + "policy": "FIFO" +} +``` + +### POST /sales/{id}/confirm-picking +```json +{ + "lines": [ + { + "sales_line_id": 1, + "picked_allocations": [ + { + "inventory_lot_id": 10, + "qty_picked": 20 + }, + { + "inventory_lot_id": 11, + "qty_picked": 10 + } + ] + } + ] +} +``` + +--- + +## 9. Adjustments & Shrinkage +### POST /stock-adjustments +```json +{ + "inventory_lot_id": 11, + "adjustment_type": "SHRINKAGE", + "reason_id": 1, + "qty_change": -1.2, + "notes": "Selisih opname" +} +``` + +### GET /stock-adjustments + +--- + +## 10. Returns +### POST /sales-returns +### POST /purchase-returns + +### POST /sales-returns +```json +{ + "sales_id": 1, + "customer_id": 1, + "return_date": "2026-04-29", + "lines": [ + { + "sales_line_id": 1, + "inventory_lot_id": 10, + "item_type_id": 1, + "item_grade_id": 1, + "qty_returned": 2, + "return_condition": "GOOD", + "resolution": "RESTOCK" + } + ] +} +``` + +--- + +## 11. Barcode / QR +### GET /barcode/lookup/{value} +Response: +```json +{ + "lot_id": 10, + "lot_code": "LOT-260428-SPA-001", + "supplier": "Supplier A", + "supplier_bank_name": "BCA", + "supplier_bank_account_number": "1234567890", + "item_type": "Jenis A", + "grade": "Grade A", + "available_qty": 30, + "warehouse": "Gudang Pusat", + "location": "Rak A1", + "status": "ACTIVE" +} +``` + +### POST /lots/{id}/print-label +### GET /lots/{id}/labels + +--- + +## 12. Reports +### GET /reports/stock-summary +### GET /reports/stock-lots +### GET /reports/purchases +### GET /reports/sales +### GET /reports/margins +### GET /reports/shrinkage +### GET /reports/supplier-quality +### GET /reports/traceability + +Contoh: +### GET /reports/traceability?sales_id=1 +### GET /reports/traceability?lot_id=10 + +--- + +## 13. Error Rules +Format error: +```json +{ + "message": "Validation error", + "errors": { + "qty_allocated": ["qty allocated melebihi stok tersedia"] + } +} +``` + +## 14. Business Validation Rules +- total allocation harus sama dengan qty sales line +- qty allocation tidak boleh melebihi available qty lot +- sorting result total + shrinkage tidak boleh melebihi input qty +- lot hold tidak boleh dipakai untuk sales allocation +- lot closed tidak boleh dipakai lagi +- transfer dan adjustment harus menghasilkan movement ledger +- receipt yang belum finalized tidak boleh dipakai untuk sales \ No newline at end of file diff --git a/docs/project-spec/walet-backend-architecture.md b/docs/project-spec/walet-backend-architecture.md new file mode 100644 index 0000000..1d08143 --- /dev/null +++ b/docs/project-spec/walet-backend-architecture.md @@ -0,0 +1,278 @@ +# Arsitektur Backend Sistem Inventory Walet + +## 1. Tujuan +Dokumen ini menjelaskan rancangan arsitektur backend untuk sistem inventory sarang burung walet berbasis lot, sorting, partial allocation, costing, dan traceability. + +## 2. Prinsip Arsitektur +- modular per domain bisnis +- transaction-safe untuk operasi stok +- audit trail wajib untuk semua mutasi lot +- source of truth ada pada lot dan movement ledger +- costing dihitung dari allocation aktual +- siap dikembangkan bertahap dari MVP ke skala lebih besar + +## 3. Modul Backend Utama +### A. Auth & Access Control +Tanggung jawab: +- login +- session/jwt +- role permission +- route guard +- audit identity + +### B. Master Data Module +Tanggung jawab: +- supplier +- customer +- item types +- item grades +- warehouses +- locations +- units +- adjustment reasons + +### C. Purchasing Module +Tanggung jawab: +- purchase header/lines +- purchase workflow +- histori harga beli + +### D. Receiving Module +Tanggung jawab: +- receipt header/lines +- qty accepted/rejected +- create lot awal +- generate label metadata + +### E. Inventory Lot Module +Tanggung jawab: +- detail lot +- stock summary +- lot status +- hold/release +- transfer +- location update + +### F. Sorting & Regrade Module +Tanggung jawab: +- sorting session +- child lot creation +- shrinkage recording +- regrade logic + +### G. Sales Module +Tanggung jawab: +- sales header/lines +- sales workflow +- picking confirmation +- finalize sales + +### H. Allocation Engine Module +Tanggung jawab: +- manual allocation validation +- FIFO suggestion +- future FEFO/hybrid strategy +- costing by allocation + +### I. Inventory Movement Ledger Module +Tanggung jawab: +- record all inventory movements +- balance validation +- immutable movement history + +### J. Adjustment & Return Module +Tanggung jawab: +- stock adjustment +- shrinkage +- purchase returns +- sales returns +- damage/reject flows + +### K. Traceability Module +Tanggung jawab: +- backward trace +- forward trace +- lot lineage +- trace report builder + +### L. Reporting Module +Tanggung jawab: +- stock summary report +- sales report +- purchase report +- margin report +- shrinkage report +- supplier quality report + +### M. Barcode / QR Module +Tanggung jawab: +- label generation +- reprint tracking +- lookup by scanned value + +## 4. Struktur Layer yang Disarankan +```text +src/ + modules/ + common/ + infrastructure/ + database/ + jobs/ +``` + +### Per module internal +```text +modules/ + purchases/ + controllers/ + services/ + repositories/ + dtos/ + entities/ + validators/ +``` + +## 5. Service Boundaries Penting +### PurchaseService +- createPurchase +- updatePurchase +- submitPurchase +- cancelPurchase + +### ReceiptService +- createReceipt +- finalizeReceipt +- generateLotsFromReceipt + +### LotService +- getLotDetail +- holdLot +- releaseLot +- transferLot +- getStockSummary + +### SortingService +- createSortingSession +- validateSortingBalance +- createChildLots + +### RegradeService +- regradeLot +- splitLotForRegrade + +### SalesService +- createSales +- updateSales +- finalizeSales +- confirmPicking + +### AllocationService +- autoAllocateFIFO +- validateManualAllocation +- calculateAllocationCost + +### MovementService +- recordReceiptMovement +- recordSortingMovement +- recordSalesMovement +- recordAdjustmentMovement +- recordTransferMovement + +### TraceService +- traceBySales +- traceByLot +- buildLotLineage + +## 6. Transaksi Database yang Wajib Atomic +Operasi berikut wajib berada dalam database transaction: +- finalize receipt + create lots + create movement +- sorting submit + child lot creation + source lot reduction + movement insert +- regrade + lot split + movement insert +- sales allocation submit + costing update +- picking confirm + lot quantity reduction + movement insert +- stock adjustment + balance update + movement insert +- returns + lot mutation + movement insert + +Kalau tidak atomic, stok akan gampang kacau. + +## 7. Source of Truth Rules +- inventory_lots menyimpan posisi qty aktif saat ini +- inventory_movements menyimpan histori mutasi yang tidak boleh hilang +- sales_allocations menyimpan asal costing penjualan +- sorting_results dan parent_lot relation menyimpan lineage hasil sortasi + +## 8. Validasi Domain Penting +### Receipt +- qty accepted + qty rejected <= qty received + +### Sorting +- total result + shrinkage <= input qty + +### Allocation +- total allocation = qty sales line +- lot status harus ACTIVE +- available qty harus cukup + +### Picking +- qty picked tidak boleh melebihi qty allocated tanpa override rule + +### Adjustment +- qty_after tidak boleh negatif + +### Regrade +- qty regrade tidak boleh melebihi available qty lot sumber + +## 9. Event / Hook Internal yang Disarankan +Walau MVP belum perlu event bus besar, internal hooks bagus untuk: +- afterReceiptFinalized +- afterSortingCompleted +- afterSalesAllocated +- afterPickingConfirmed +- afterAdjustmentCreated + +Hook bisa dipakai untuk: +- recalculation summary +- audit log tambahan +- notification +- async report cache refresh + +## 10. Caching Strategy +- stock summary boleh dicache ringan +- lot detail sebaiknya fresh atau cache sangat pendek +- reports boleh async/precomputed bila sudah besar +- traceability query perlu index bagus, jangan terlalu mengandalkan cache dulu + +## 11. Database Index Priorities +Index penting: +- inventory_lots(item_type_id, item_grade_id, warehouse_id) +- inventory_lots(status) +- sales_allocations(sales_line_id) +- sales_allocations(inventory_lot_id) +- inventory_movements(inventory_lot_id, movement_date) +- purchases(supplier_id, purchase_date) +- sales(customer_id, sales_date) + +## 12. Background Jobs yang Bisa Ditambahkan +- nightly stock summary refresh +- report materialization +- aging recalculation +- QR label batch generation +- alert stok menipis + +## 13. Security & Audit +- semua endpoint protected +- role permission check di service layer, bukan UI saja +- audit log simpan user, waktu, aksi, reference +- label reprint dan stock adjustment harus ekstra jelas auditnya + +## 14. Rekomendasi Stack Backend +Pilihan aman: +- Node.js + NestJS / Express terstruktur +- PostgreSQL +- Prisma / TypeORM / Knex sesuai preferensi +- Redis opsional untuk cache dan queue + +Kalau mau lebih enterprise, NestJS cocok karena modular dan rapi. + +## 15. Kesimpulan +Backend sistem walet ini harus dibangun dengan fokus utama pada integritas transaksi stok. Modul paling kritis adalah inventory lot, allocation engine, movement ledger, sorting/regrade, dan traceability. Kalau lima bagian ini kuat, sisanya relatif mengikuti. \ No newline at end of file diff --git a/docs/project-spec/walet-component-tree.md b/docs/project-spec/walet-component-tree.md new file mode 100644 index 0000000..09a065b --- /dev/null +++ b/docs/project-spec/walet-component-tree.md @@ -0,0 +1,277 @@ +# Component Tree Per Halaman Sistem Inventory Walet + +## 1. Dashboard +```text +DashboardPage +├── AppShell +├── DashboardHeader +├── MetricsGrid +│ ├── TotalStockCard +│ ├── InventoryValueCard +│ ├── PurchaseThisMonthCard +│ ├── SalesThisMonthCard +│ └── ShrinkageThisMonthCard +├── StockSummaryChart +├── PurchaseVsSalesChart +├── SupplierQualityChart +├── AgingAlertPanel +└── QuickActionPanel +``` + +## 2. Purchase List +```text +PurchaseListPage +├── AppShell +├── PageHeader +├── PurchaseFilterBar +├── PurchaseToolbar +└── PurchaseTable + ├── TableToolbar + ├── StatusBadge + └── Pagination +``` + +## 3. Purchase Form +```text +PurchaseFormPage +├── AppShell +├── PageHeader +├── PurchaseHeaderForm +│ ├── SupplierSelect +│ ├── PurchaseDateInput +│ ├── SupplierInvoiceInput +│ └── NotesTextarea +├── PurchaseLineEditor +│ ├── PurchaseLineRow +│ │ ├── ItemTypeSelect +│ │ ├── ItemGradeSelect +│ │ ├── QtyInput +│ │ ├── UnitSelect +│ │ ├── UnitPriceInput +│ │ ├── ClassificationStatusSelect +│ │ └── LineSubtotalCell +│ └── AddLineButton +├── PurchaseSummaryCard +└── StickyActionFooter +``` + +## 4. Receipt Form +```text +ReceiptFormPage +├── AppShell +├── PageHeader +├── ReceiptHeaderForm +├── ReceiptLineTable +│ ├── ReceiptLineRow +│ │ ├── OrderedQtyCell +│ │ ├── ReceivedQtyInput +│ │ ├── AcceptedQtyInput +│ │ ├── RejectedQtyInput +│ │ ├── UnitCostInput +│ │ ├── WarehouseSelect +│ │ └── WarehouseLocationSelect +├── GenerateLotPanel +└── StickyActionFooter +``` + +## 5. Stock Lot List +```text +StockLotListPage +├── AppShell +├── PageHeader +├── LotFilterBar +├── LotToolbar +└── LotTable + ├── StatusBadge + ├── AgingBadge + ├── LotQuickActionsMenu + └── Pagination +``` + +## 6. Lot Detail +```text +LotDetailPage +├── AppShell +├── PageHeader +├── LotSummaryCard +├── LotIdentitySection +├── LotQuantitySection +├── LotTraceSection +│ ├── ParentLotCard +│ ├── ChildLotsTable +│ └── SourceReferenceCard +├── LotMovementTimeline +├── LotSalesUsageTable +├── LotLabelPanel +└── LotActionPanel + ├── HoldLotButton + ├── ReleaseLotButton + ├── TransferLotButton + ├── RegradeLotButton + └── PrintLabelButton +``` + +## 7. Sorting Session Form +```text +SortingSessionFormPage +├── AppShell +├── PageHeader +├── SourceLotSelector +├── SourceLotSummaryCard +├── SortingResultEditor +│ ├── SortingResultRow +│ │ ├── ItemTypeSelect +│ │ ├── ItemGradeSelect +│ │ ├── QtyResultInput +│ │ └── CostInput +│ └── AddResultLineButton +├── ShrinkageInputCard +└── StickyActionFooter +``` + +## 8. Sales Form +```text +SalesFormPage +├── AppShell +├── PageHeader +├── SalesHeaderForm +│ ├── CustomerSelect +│ ├── SalesDateInput +│ └── NotesTextarea +├── SalesLineEditor +│ ├── SalesLineRow +│ │ ├── ItemTypeSelect +│ │ ├── ItemGradeSelect +│ │ ├── QtyInput +│ │ ├── UnitSelect +│ │ ├── SellingPriceInput +│ │ └── LineSubtotalCell +│ └── AddLineButton +├── SalesSummaryCard +└── StickyActionFooter +``` + +## 9. Allocation Screen +```text +SalesAllocationPage +├── AppShell +├── PageHeader +├── SalesLineSummaryCard +├── AllocationToolbar +│ ├── AutoAllocateButton +│ ├── AllocationPolicySelect +│ └── RefreshStockButton +├── AvailableLotTable +│ ├── LotRow +│ │ ├── LotCodeCell +│ │ ├── SupplierCell +│ │ ├── AvailableQtyCell +│ │ ├── UnitCostCell +│ │ ├── FIFORecommendationBadge +│ │ └── AllocateQtyInput +├── AllocationSummaryCard +└── StickyActionFooter +``` + +## 10. Picking Screen +```text +PickingPage +├── AppShell +├── PageHeader +├── PickingSummaryCard +├── QrScannerPanel +├── PickingAllocationList +│ ├── PickingAllocationRow +│ │ ├── LotInfoCard +│ │ ├── AllocatedQtyCell +│ │ ├── PickedQtyInput +│ │ └── VarianceBadge +└── StickyActionFooter +``` + +## 11. Barcode Lookup +```text +BarcodeLookupPage +├── AppShell +├── PageHeader +├── QrScannerPanel +├── ManualBarcodeInput +├── LookupResultCard +├── TraceSummaryPanel +└── QuickActionPanel +``` + +## 12. Adjustment Form +```text +AdjustmentFormPage +├── AppShell +├── PageHeader +├── LotSelector +├── LotSnapshotCard +├── AdjustmentForm +│ ├── AdjustmentTypeSelect +│ ├── ReasonSelect +│ ├── QtyBeforeReadonly +│ ├── QtyChangeInput +│ ├── QtyAfterPreview +│ ├── CostImpactPreview +│ └── NotesTextarea +└── StickyActionFooter +``` + +## 13. Reports Page +```text +ReportPage +├── AppShell +├── PageHeader +├── ReportFilterBar +├── ReportSummaryCards +├── ReportChartSection +├── ReportTableSection +└── ExportActionBar +``` + +## 14. Shared Critical Components +```text +AppShell +├── Sidebar +├── Topbar +├── Breadcrumb +└── ContentWrapper + +DataTable +├── FilterBar +├── ColumnVisibilityToggle +├── ExportButton +├── Pagination +└── EmptyState + +QrScannerPanel +├── CameraPreview +├── ScanOverlay +├── ScanResultBadge +└── ManualFallbackInput +``` + +## 15. Prioritas Component Build +Urutan komponen paling penting dibangun dulu: +1. AppShell +2. DataTable +3. StatusBadge / AgingBadge +4. Form primitives +5. PurchaseLineEditor +6. ReceiptLineTable +7. LotTable +8. LotSummaryCard +9. LotMovementTimeline +10. SalesLineEditor +11. AvailableLotTable +12. AllocationSummaryCard +13. QrScannerPanel +14. PickingAllocationList +15. ReportFilterBar + +## 16. Catatan +- halaman lot detail dan allocation adalah pusat kompleksitas produk +- QR scanner dan movement timeline adalah komponen pembeda +- semua editor multi-line harus reusable agar purchase, receipt, sales, dan sorting lebih cepat dibangun \ No newline at end of file diff --git a/docs/project-spec/walet-erd-dbml.dbml b/docs/project-spec/walet-erd-dbml.dbml new file mode 100644 index 0000000..3210333 --- /dev/null +++ b/docs/project-spec/walet-erd-dbml.dbml @@ -0,0 +1,441 @@ +Project walet_inventory { + database_type: "PostgreSQL" + Note: 'ERD importable schema for lot-based traceable walet inventory system' +} + +Table suppliers { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + phone varchar + email varchar + bank_name varchar + bank_account_number varchar + address text + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table customers { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + phone varchar + email varchar + bank_name varchar + bank_account_number varchar + address text + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table roles { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table users { + id bigint [pk, increment] + role_id bigint [not null] + name varchar [not null] + email varchar + phone varchar + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table units { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table item_types { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + description text + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table item_grades { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + rank_order int + description text + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table warehouses { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + address text + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table warehouse_locations { + id bigint [pk, increment] + warehouse_id bigint [not null] + code varchar [not null] + name varchar [not null] + location_type varchar + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] + + indexes { + (warehouse_id, code) [unique] + } +} + +Table adjustment_reasons { + id bigint [pk, increment] + code varchar [not null, unique] + name varchar [not null] + category varchar [not null] + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table purchases { + id bigint [pk, increment] + purchase_no varchar [not null, unique] + supplier_id bigint [not null] + purchase_date date [not null] + supplier_invoice_no varchar + status varchar [not null, default: 'DRAFT'] + notes text + created_by bigint [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table purchase_lines { + id bigint [pk, increment] + purchase_id bigint [not null] + item_type_id bigint [not null] + item_grade_id bigint + qty_ordered decimal(18,3) [not null] + unit_id bigint [not null] + unit_price decimal(18,2) [not null] + subtotal decimal(18,2) [not null] + classification_status varchar [not null, default: 'FINAL'] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table receipts { + id bigint [pk, increment] + receipt_no varchar [not null, unique] + purchase_id bigint [not null] + supplier_id bigint [not null] + receipt_date date [not null] + status varchar [not null, default: 'DRAFT'] + notes text + received_by bigint [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table receipt_lines { + id bigint [pk, increment] + receipt_id bigint [not null] + purchase_line_id bigint [not null] + item_type_id bigint [not null] + item_grade_id bigint + qty_received decimal(18,3) [not null] + qty_accepted decimal(18,3) [not null] + qty_rejected decimal(18,3) [not null, default: 0] + unit_id bigint [not null] + unit_cost decimal(18,2) [not null] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table inventory_lots { + id bigint [pk, increment] + lot_code varchar [not null, unique] + parent_lot_id bigint + source_type varchar [not null] + source_ref_id bigint + supplier_id bigint + purchase_id bigint + purchase_line_id bigint + receipt_id bigint + receipt_line_id bigint + item_type_id bigint [not null] + item_grade_id bigint [not null] + warehouse_id bigint [not null] + warehouse_location_id bigint + original_qty decimal(18,3) [not null] + available_qty decimal(18,3) [not null] + reserved_qty decimal(18,3) [not null, default: 0] + damaged_qty decimal(18,3) [not null, default: 0] + shrinkage_qty decimal(18,3) [not null, default: 0] + unit_id bigint [not null] + unit_cost decimal(18,2) [not null] + received_at timestamp [not null] + status varchar [not null, default: 'ACTIVE'] + qr_code_value varchar + barcode_value varchar + notes text + created_at timestamp [not null] + updated_at timestamp [not null] + + indexes { + (item_type_id, item_grade_id, warehouse_id) + (supplier_id, item_type_id, item_grade_id) + } +} + +Table sorting_sessions { + id bigint [pk, increment] + sorting_no varchar [not null, unique] + source_lot_id bigint [not null] + sorting_date timestamp [not null] + input_qty decimal(18,3) [not null] + output_qty decimal(18,3) [not null] + shrinkage_qty decimal(18,3) [not null, default: 0] + notes text + sorted_by bigint [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table sorting_results { + id bigint [pk, increment] + sorting_session_id bigint [not null] + result_lot_id bigint [not null] + item_type_id bigint [not null] + item_grade_id bigint [not null] + qty_result decimal(18,3) [not null] + unit_cost decimal(18,2) [not null] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table sales { + id bigint [pk, increment] + sales_no varchar [not null, unique] + customer_id bigint [not null] + sales_date date [not null] + status varchar [not null, default: 'DRAFT'] + notes text + created_by bigint [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table sales_lines { + id bigint [pk, increment] + sales_id bigint [not null] + item_type_id bigint [not null] + item_grade_id bigint [not null] + qty_sold decimal(18,3) [not null] + unit_id bigint [not null] + selling_price decimal(18,2) [not null] + subtotal decimal(18,2) [not null] + costing_total decimal(18,2) [not null, default: 0] + gross_margin decimal(18,2) [not null, default: 0] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table sales_allocations { + id bigint [pk, increment] + sales_line_id bigint [not null] + inventory_lot_id bigint [not null] + qty_allocated decimal(18,3) [not null] + unit_cost decimal(18,2) [not null] + total_cost decimal(18,2) [not null] + allocated_at timestamp [not null] + allocated_by bigint [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table inventory_movements { + id bigint [pk, increment] + movement_no varchar [not null, unique] + movement_type varchar [not null] + inventory_lot_id bigint [not null] + related_lot_id bigint + warehouse_id bigint [not null] + warehouse_location_id bigint + qty_in decimal(18,3) [not null, default: 0] + qty_out decimal(18,3) [not null, default: 0] + balance_after decimal(18,3) [not null] + unit_cost decimal(18,2) [not null] + reference_type varchar [not null] + reference_id bigint [not null] + reason_id bigint + movement_date timestamp [not null] + created_by bigint [not null] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table sales_returns { + id bigint [pk, increment] + sales_id bigint [not null] + customer_id bigint [not null] + return_date date [not null] + status varchar [not null, default: 'DRAFT'] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table sales_return_lines { + id bigint [pk, increment] + sales_return_id bigint [not null] + sales_line_id bigint [not null] + inventory_lot_id bigint + item_type_id bigint [not null] + item_grade_id bigint [not null] + qty_returned decimal(18,3) [not null] + return_condition varchar + resolution varchar + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table purchase_returns { + id bigint [pk, increment] + purchase_id bigint [not null] + supplier_id bigint [not null] + return_date date [not null] + status varchar [not null, default: 'DRAFT'] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table purchase_return_lines { + id bigint [pk, increment] + purchase_return_id bigint [not null] + inventory_lot_id bigint [not null] + qty_returned decimal(18,3) [not null] + unit_cost decimal(18,2) [not null] + notes text + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table stock_adjustments { + id bigint [pk, increment] + adjustment_no varchar [not null, unique] + inventory_lot_id bigint [not null] + adjustment_type varchar [not null] + reason_id bigint [not null] + qty_before decimal(18,3) [not null] + qty_change decimal(18,3) [not null] + qty_after decimal(18,3) [not null] + cost_impact decimal(18,2) [not null, default: 0] + adjustment_date timestamp [not null] + notes text + created_by bigint [not null] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Table lot_labels { + id bigint [pk, increment] + inventory_lot_id bigint [not null] + label_type varchar [not null] + label_value varchar [not null] + printed_at timestamp + printed_by bigint + print_count int [not null, default: 0] + status varchar [not null, default: 'ACTIVE'] + created_at timestamp [not null] + updated_at timestamp [not null] +} + +Ref: users.role_id > roles.id +Ref: warehouse_locations.warehouse_id > warehouses.id +Ref: purchases.supplier_id > suppliers.id +Ref: purchases.created_by > users.id +Ref: purchase_lines.purchase_id > purchases.id +Ref: purchase_lines.item_type_id > item_types.id +Ref: purchase_lines.item_grade_id > item_grades.id +Ref: purchase_lines.unit_id > units.id +Ref: receipts.purchase_id > purchases.id +Ref: receipts.supplier_id > suppliers.id +Ref: receipts.received_by > users.id +Ref: receipt_lines.receipt_id > receipts.id +Ref: receipt_lines.purchase_line_id > purchase_lines.id +Ref: receipt_lines.item_type_id > item_types.id +Ref: receipt_lines.item_grade_id > item_grades.id +Ref: receipt_lines.unit_id > units.id +Ref: inventory_lots.parent_lot_id > inventory_lots.id +Ref: inventory_lots.supplier_id > suppliers.id +Ref: inventory_lots.purchase_id > purchases.id +Ref: inventory_lots.purchase_line_id > purchase_lines.id +Ref: inventory_lots.receipt_id > receipts.id +Ref: inventory_lots.receipt_line_id > receipt_lines.id +Ref: inventory_lots.item_type_id > item_types.id +Ref: inventory_lots.item_grade_id > item_grades.id +Ref: inventory_lots.warehouse_id > warehouses.id +Ref: inventory_lots.warehouse_location_id > warehouse_locations.id +Ref: inventory_lots.unit_id > units.id +Ref: sorting_sessions.source_lot_id > inventory_lots.id +Ref: sorting_sessions.sorted_by > users.id +Ref: sorting_results.sorting_session_id > sorting_sessions.id +Ref: sorting_results.result_lot_id > inventory_lots.id +Ref: sorting_results.item_type_id > item_types.id +Ref: sorting_results.item_grade_id > item_grades.id +Ref: sales.customer_id > customers.id +Ref: sales.created_by > users.id +Ref: sales_lines.sales_id > sales.id +Ref: sales_lines.item_type_id > item_types.id +Ref: sales_lines.item_grade_id > item_grades.id +Ref: sales_lines.unit_id > units.id +Ref: sales_allocations.sales_line_id > sales_lines.id +Ref: sales_allocations.inventory_lot_id > inventory_lots.id +Ref: sales_allocations.allocated_by > users.id +Ref: inventory_movements.inventory_lot_id > inventory_lots.id +Ref: inventory_movements.related_lot_id > inventory_lots.id +Ref: inventory_movements.warehouse_id > warehouses.id +Ref: inventory_movements.warehouse_location_id > warehouse_locations.id +Ref: inventory_movements.reason_id > adjustment_reasons.id +Ref: inventory_movements.created_by > users.id +Ref: sales_returns.sales_id > sales.id +Ref: sales_returns.customer_id > customers.id +Ref: sales_return_lines.sales_return_id > sales_returns.id +Ref: sales_return_lines.sales_line_id > sales_lines.id +Ref: sales_return_lines.inventory_lot_id > inventory_lots.id +Ref: sales_return_lines.item_type_id > item_types.id +Ref: sales_return_lines.item_grade_id > item_grades.id +Ref: purchase_returns.purchase_id > purchases.id +Ref: purchase_returns.supplier_id > suppliers.id +Ref: purchase_return_lines.purchase_return_id > purchase_returns.id +Ref: purchase_return_lines.inventory_lot_id > inventory_lots.id +Ref: stock_adjustments.inventory_lot_id > inventory_lots.id +Ref: stock_adjustments.reason_id > adjustment_reasons.id +Ref: stock_adjustments.created_by > users.id +Ref: lot_labels.inventory_lot_id > inventory_lots.id +Ref: lot_labels.printed_by > users.id diff --git a/docs/project-spec/walet-erd-schema.html b/docs/project-spec/walet-erd-schema.html new file mode 100644 index 0000000..b85c41b --- /dev/null +++ b/docs/project-spec/walet-erd-schema.html @@ -0,0 +1,442 @@ +

ERD Schema Sistem Inventory Walet

Project walet_inventory {
+  database_type: "PostgreSQL"
+  Note: 'ERD importable schema for lot-based traceable walet inventory system'
+}
+
+Table suppliers {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  phone varchar
+  email varchar
+  bank_name varchar
+  bank_account_number varchar
+  address text
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table customers {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  phone varchar
+  email varchar
+  bank_name varchar
+  bank_account_number varchar
+  address text
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table roles {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table users {
+  id bigint [pk, increment]
+  role_id bigint [not null]
+  name varchar [not null]
+  email varchar
+  phone varchar
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table units {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table item_types {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  description text
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table item_grades {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  rank_order int
+  description text
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table warehouses {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  address text
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table warehouse_locations {
+  id bigint [pk, increment]
+  warehouse_id bigint [not null]
+  code varchar [not null]
+  name varchar [not null]
+  location_type varchar
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+
+  indexes {
+    (warehouse_id, code) [unique]
+  }
+}
+
+Table adjustment_reasons {
+  id bigint [pk, increment]
+  code varchar [not null, unique]
+  name varchar [not null]
+  category varchar [not null]
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table purchases {
+  id bigint [pk, increment]
+  purchase_no varchar [not null, unique]
+  supplier_id bigint [not null]
+  purchase_date date [not null]
+  supplier_invoice_no varchar
+  status varchar [not null, default: 'DRAFT']
+  notes text
+  created_by bigint [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table purchase_lines {
+  id bigint [pk, increment]
+  purchase_id bigint [not null]
+  item_type_id bigint [not null]
+  item_grade_id bigint
+  qty_ordered decimal(18,3) [not null]
+  unit_id bigint [not null]
+  unit_price decimal(18,2) [not null]
+  subtotal decimal(18,2) [not null]
+  classification_status varchar [not null, default: 'FINAL']
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table receipts {
+  id bigint [pk, increment]
+  receipt_no varchar [not null, unique]
+  purchase_id bigint [not null]
+  supplier_id bigint [not null]
+  receipt_date date [not null]
+  status varchar [not null, default: 'DRAFT']
+  notes text
+  received_by bigint [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table receipt_lines {
+  id bigint [pk, increment]
+  receipt_id bigint [not null]
+  purchase_line_id bigint [not null]
+  item_type_id bigint [not null]
+  item_grade_id bigint
+  qty_received decimal(18,3) [not null]
+  qty_accepted decimal(18,3) [not null]
+  qty_rejected decimal(18,3) [not null, default: 0]
+  unit_id bigint [not null]
+  unit_cost decimal(18,2) [not null]
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table inventory_lots {
+  id bigint [pk, increment]
+  lot_code varchar [not null, unique]
+  parent_lot_id bigint
+  source_type varchar [not null]
+  source_ref_id bigint
+  supplier_id bigint
+  purchase_id bigint
+  purchase_line_id bigint
+  receipt_id bigint
+  receipt_line_id bigint
+  item_type_id bigint [not null]
+  item_grade_id bigint [not null]
+  warehouse_id bigint [not null]
+  warehouse_location_id bigint
+  original_qty decimal(18,3) [not null]
+  available_qty decimal(18,3) [not null]
+  reserved_qty decimal(18,3) [not null, default: 0]
+  damaged_qty decimal(18,3) [not null, default: 0]
+  shrinkage_qty decimal(18,3) [not null, default: 0]
+  unit_id bigint [not null]
+  unit_cost decimal(18,2) [not null]
+  received_at timestamp [not null]
+  status varchar [not null, default: 'ACTIVE']
+  qr_code_value varchar
+  barcode_value varchar
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+
+  indexes {
+    (item_type_id, item_grade_id, warehouse_id)
+    (supplier_id, item_type_id, item_grade_id)
+  }
+}
+
+Table sorting_sessions {
+  id bigint [pk, increment]
+  sorting_no varchar [not null, unique]
+  source_lot_id bigint [not null]
+  sorting_date timestamp [not null]
+  input_qty decimal(18,3) [not null]
+  output_qty decimal(18,3) [not null]
+  shrinkage_qty decimal(18,3) [not null, default: 0]
+  notes text
+  sorted_by bigint [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table sorting_results {
+  id bigint [pk, increment]
+  sorting_session_id bigint [not null]
+  result_lot_id bigint [not null]
+  item_type_id bigint [not null]
+  item_grade_id bigint [not null]
+  qty_result decimal(18,3) [not null]
+  unit_cost decimal(18,2) [not null]
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table sales {
+  id bigint [pk, increment]
+  sales_no varchar [not null, unique]
+  customer_id bigint [not null]
+  sales_date date [not null]
+  status varchar [not null, default: 'DRAFT']
+  notes text
+  created_by bigint [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table sales_lines {
+  id bigint [pk, increment]
+  sales_id bigint [not null]
+  item_type_id bigint [not null]
+  item_grade_id bigint [not null]
+  qty_sold decimal(18,3) [not null]
+  unit_id bigint [not null]
+  selling_price decimal(18,2) [not null]
+  subtotal decimal(18,2) [not null]
+  costing_total decimal(18,2) [not null, default: 0]
+  gross_margin decimal(18,2) [not null, default: 0]
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table sales_allocations {
+  id bigint [pk, increment]
+  sales_line_id bigint [not null]
+  inventory_lot_id bigint [not null]
+  qty_allocated decimal(18,3) [not null]
+  unit_cost decimal(18,2) [not null]
+  total_cost decimal(18,2) [not null]
+  allocated_at timestamp [not null]
+  allocated_by bigint [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table inventory_movements {
+  id bigint [pk, increment]
+  movement_no varchar [not null, unique]
+  movement_type varchar [not null]
+  inventory_lot_id bigint [not null]
+  related_lot_id bigint
+  warehouse_id bigint [not null]
+  warehouse_location_id bigint
+  qty_in decimal(18,3) [not null, default: 0]
+  qty_out decimal(18,3) [not null, default: 0]
+  balance_after decimal(18,3) [not null]
+  unit_cost decimal(18,2) [not null]
+  reference_type varchar [not null]
+  reference_id bigint [not null]
+  reason_id bigint
+  movement_date timestamp [not null]
+  created_by bigint [not null]
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table sales_returns {
+  id bigint [pk, increment]
+  sales_id bigint [not null]
+  customer_id bigint [not null]
+  return_date date [not null]
+  status varchar [not null, default: 'DRAFT']
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table sales_return_lines {
+  id bigint [pk, increment]
+  sales_return_id bigint [not null]
+  sales_line_id bigint [not null]
+  inventory_lot_id bigint
+  item_type_id bigint [not null]
+  item_grade_id bigint [not null]
+  qty_returned decimal(18,3) [not null]
+  return_condition varchar
+  resolution varchar
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table purchase_returns {
+  id bigint [pk, increment]
+  purchase_id bigint [not null]
+  supplier_id bigint [not null]
+  return_date date [not null]
+  status varchar [not null, default: 'DRAFT']
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table purchase_return_lines {
+  id bigint [pk, increment]
+  purchase_return_id bigint [not null]
+  inventory_lot_id bigint [not null]
+  qty_returned decimal(18,3) [not null]
+  unit_cost decimal(18,2) [not null]
+  notes text
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table stock_adjustments {
+  id bigint [pk, increment]
+  adjustment_no varchar [not null, unique]
+  inventory_lot_id bigint [not null]
+  adjustment_type varchar [not null]
+  reason_id bigint [not null]
+  qty_before decimal(18,3) [not null]
+  qty_change decimal(18,3) [not null]
+  qty_after decimal(18,3) [not null]
+  cost_impact decimal(18,2) [not null, default: 0]
+  adjustment_date timestamp [not null]
+  notes text
+  created_by bigint [not null]
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Table lot_labels {
+  id bigint [pk, increment]
+  inventory_lot_id bigint [not null]
+  label_type varchar [not null]
+  label_value varchar [not null]
+  printed_at timestamp
+  printed_by bigint
+  print_count int [not null, default: 0]
+  status varchar [not null, default: 'ACTIVE']
+  created_at timestamp [not null]
+  updated_at timestamp [not null]
+}
+
+Ref: users.role_id > roles.id
+Ref: warehouse_locations.warehouse_id > warehouses.id
+Ref: purchases.supplier_id > suppliers.id
+Ref: purchases.created_by > users.id
+Ref: purchase_lines.purchase_id > purchases.id
+Ref: purchase_lines.item_type_id > item_types.id
+Ref: purchase_lines.item_grade_id > item_grades.id
+Ref: purchase_lines.unit_id > units.id
+Ref: receipts.purchase_id > purchases.id
+Ref: receipts.supplier_id > suppliers.id
+Ref: receipts.received_by > users.id
+Ref: receipt_lines.receipt_id > receipts.id
+Ref: receipt_lines.purchase_line_id > purchase_lines.id
+Ref: receipt_lines.item_type_id > item_types.id
+Ref: receipt_lines.item_grade_id > item_grades.id
+Ref: receipt_lines.unit_id > units.id
+Ref: inventory_lots.parent_lot_id > inventory_lots.id
+Ref: inventory_lots.supplier_id > suppliers.id
+Ref: inventory_lots.purchase_id > purchases.id
+Ref: inventory_lots.purchase_line_id > purchase_lines.id
+Ref: inventory_lots.receipt_id > receipts.id
+Ref: inventory_lots.receipt_line_id > receipt_lines.id
+Ref: inventory_lots.item_type_id > item_types.id
+Ref: inventory_lots.item_grade_id > item_grades.id
+Ref: inventory_lots.warehouse_id > warehouses.id
+Ref: inventory_lots.warehouse_location_id > warehouse_locations.id
+Ref: inventory_lots.unit_id > units.id
+Ref: sorting_sessions.source_lot_id > inventory_lots.id
+Ref: sorting_sessions.sorted_by > users.id
+Ref: sorting_results.sorting_session_id > sorting_sessions.id
+Ref: sorting_results.result_lot_id > inventory_lots.id
+Ref: sorting_results.item_type_id > item_types.id
+Ref: sorting_results.item_grade_id > item_grades.id
+Ref: sales.customer_id > customers.id
+Ref: sales.created_by > users.id
+Ref: sales_lines.sales_id > sales.id
+Ref: sales_lines.item_type_id > item_types.id
+Ref: sales_lines.item_grade_id > item_grades.id
+Ref: sales_lines.unit_id > units.id
+Ref: sales_allocations.sales_line_id > sales_lines.id
+Ref: sales_allocations.inventory_lot_id > inventory_lots.id
+Ref: sales_allocations.allocated_by > users.id
+Ref: inventory_movements.inventory_lot_id > inventory_lots.id
+Ref: inventory_movements.related_lot_id > inventory_lots.id
+Ref: inventory_movements.warehouse_id > warehouses.id
+Ref: inventory_movements.warehouse_location_id > warehouse_locations.id
+Ref: inventory_movements.reason_id > adjustment_reasons.id
+Ref: inventory_movements.created_by > users.id
+Ref: sales_returns.sales_id > sales.id
+Ref: sales_returns.customer_id > customers.id
+Ref: sales_return_lines.sales_return_id > sales_returns.id
+Ref: sales_return_lines.sales_line_id > sales_lines.id
+Ref: sales_return_lines.inventory_lot_id > inventory_lots.id
+Ref: sales_return_lines.item_type_id > item_types.id
+Ref: sales_return_lines.item_grade_id > item_grades.id
+Ref: purchase_returns.purchase_id > purchases.id
+Ref: purchase_returns.supplier_id > suppliers.id
+Ref: purchase_return_lines.purchase_return_id > purchase_returns.id
+Ref: purchase_return_lines.inventory_lot_id > inventory_lots.id
+Ref: stock_adjustments.inventory_lot_id > inventory_lots.id
+Ref: stock_adjustments.reason_id > adjustment_reasons.id
+Ref: stock_adjustments.created_by > users.id
+Ref: lot_labels.inventory_lot_id > inventory_lots.id
+Ref: lot_labels.printed_by > users.id
+
\ No newline at end of file diff --git a/docs/project-spec/walet-erd-schema.pdf b/docs/project-spec/walet-erd-schema.pdf new file mode 100644 index 0000000..b449016 Binary files /dev/null and b/docs/project-spec/walet-erd-schema.pdf differ diff --git a/docs/project-spec/walet-figma-design-brief.md b/docs/project-spec/walet-figma-design-brief.md new file mode 100644 index 0000000..757049e --- /dev/null +++ b/docs/project-spec/walet-figma-design-brief.md @@ -0,0 +1,253 @@ +# Figma-Ready Design Brief +## AbelBirdnest Stock + +## 1. Tujuan Brief +Dokumen ini dibuat agar tim UI/UX atau desainer Figma bisa langsung mulai mendesain tanpa harus membaca seluruh dokumen teknis dari nol. + +Target desain: +- desain aplikasi web responsive +- prioritas pada operasional gudang, purchasing, sales, dan traceability +- desain harus cepat dipakai, bukan sekadar cantik +- mobile usability penting untuk flow scan, receiving, transfer, dan picking + +## 2. Konteks Produk +`AbelBirdnest Stock` adalah sistem inventory sarang burung walet berbasis lot/batch dengan kemampuan: +- pembelian multi jenis dan multi grade +- penerimaan barang dan pembentukan lot +- sortasi / regrade +- penjualan parsial dari banyak lot +- costing berbasis allocation nyata +- traceability supplier ke customer +- barcode / QR lookup dan scan workflow + +## 3. User Utama +### Owner +Tujuan: +- melihat dashboard +- melihat laporan, margin, shrinkage, traceability + +### Admin Purchasing +Tujuan: +- membuat pembelian +- mengelola supplier +- melihat histori harga + +### Admin Gudang +Tujuan: +- menerima barang +- mengelola lot +- transfer, hold, release, adjustment +- scan QR/barcode + +### Tim QC / Sortasi +Tujuan: +- sortasi lot +- regrade +- catat shrinkage dan reject + +### Admin Sales +Tujuan: +- buat sales order +- allocate lot +- picking +- cek trace sumber barang + +## 4. Platform dan Breakpoints +### Primary +- Desktop web untuk office/admin tasks +- Mobile web untuk gudang/scan tasks + +### Breakpoints saran +- Mobile: 360-480px +- Tablet: 768px +- Desktop: 1280px+ + +## 5. Design Principles +- information dense but clear +- focus on traceability and status visibility +- minimize typing for operational workflows +- prioritize fast actions and readable tables +- destructive or high-risk actions must be explicit +- quantity, cost, status, and lot identity must always stand out + +## 6. Visual Direction +### Tone +- modern business app +- clean, efficient, trustworthy +- operational, not decorative + +### Suggested look +- neutral base colors +- strong status color coding +- lots of cards, tables, badges, and side panels +- subtle but clear hierarchy + +### Suggested color logic +- Active: green +- Hold: yellow/orange +- Closed: gray +- Rejected: red +- Draft: blue-gray +- Alert/shrinkage variance: red/orange + +## 7. Typography Guidance +- clear sans-serif +- body 14-16px desktop +- compact but readable table text 12-14px +- headings should clearly separate sections + +## 8. Core UX Priorities +### A. Lot identity must be obvious +Every lot-related screen should surface: +- lot code +- item type +- grade +- available qty +- supplier +- status +- warehouse/location + +### B. Allocation must feel safe +Sales allocation UI must clearly show: +- required qty +- selected qty +- remaining qty +- cost impact +- recommended FIFO lots + +### C. Scan flow must be fast +For mobile scan pages: +- camera first +- manual input fallback +- instant lookup result +- quick actions after scan + +### D. Movement history must be readable +Movement timeline should be understandable even by non-technical user. + +## 9. Design Output Expected from UI/UX Team +Minimal deliverables: +- sitemap / screen map +- low-fi wireframe cleanup +- hi-fi UI for key screens +- mobile variant for scan flows +- clickable prototype for core flows +- handoff specs for frontend team + +## 10. Screen Priority for Hi-Fi Design +Prioritas desain visual: +1. Login +2. Dashboard +3. Purchase Form +4. Receipt Form +5. Stock Lot List +6. Lot Detail +7. Sales Form +8. Allocation Screen +9. Picking Screen +10. Barcode Lookup +11. Sorting Session Form +12. Reports + +## 11. Key Flows to Prototype +### Flow 1. Purchase to Receipt +- create purchase +- receive goods +- generate lot +- view lot detail + +### Flow 2. Lot to Sorting +- select lot +- create sorting session +- create child lots +- review results + +### Flow 3. Sales Allocation +- create sales order +- choose item/grade +- allocate from multiple lots +- confirm costing +- proceed to picking + +### Flow 4. Scan Lookup +- open scan page +- scan QR +- display lot data +- navigate to lot detail or action page + +## 12. Components that Need Design Attention +- data table +- filter bar +- summary cards +- status badges +- qty editor +- cost summary panel +- movement timeline +- QR scanner panel +- traceability source panel +- sticky action footer + +## 13. Special UX Notes +### A. Numbers +Design must support large financial values and decimal quantities. + +### B. Tables +Many screens are data-heavy. Table readability is critical. + +### C. Status +Status should never rely on color only. Add badges/text. + +### D. Bank fields +Supplier and customer screens must include: +- bank name +- bank account number + +### E. Audit-sensitive actions +Actions like hold, release, regrade, adjustment, finalize should feel deliberate. + +## 14. Suggested Figma File Structure +```text +AbelBirdnest Stock Design + 00 Cover + 01 Foundations + - Colors + - Typography + - Spacing + - Icons + 02 Components + - Buttons + - Inputs + - Tables + - Cards + - Badges + - Modals + - Scanner + 03 Desktop Screens + 04 Mobile Screens + 05 Prototypes + 06 Dev Handoff +``` + +## 15. Suggested Foundations +### Components to define early +- Button primary/secondary/destructive +- Text field +- Number field +- Select field +- Date picker +- Badge status +- Table row states +- Card summary +- Drawer/modal +- Timeline item + +## 16. Success Criteria for Design +Design dianggap berhasil jika: +- user bisa paham status lot dalam beberapa detik +- sales allocation tidak membingungkan +- scan flow cepat dipakai di HP +- traceability tidak terasa kompleks walau datanya kaya +- tabel tetap nyaman dibaca walau padat + +## 17. Penutup +Brief ini harus dipakai bersama dokumen wireframe, component tree, dan frontend structure. Fokus utama desain bukan sekadar estetika, tapi membuat proses inventory walet yang kompleks terasa jelas, aman, dan cepat dipakai. diff --git a/docs/project-spec/walet-frontend-structure.md b/docs/project-spec/walet-frontend-structure.md new file mode 100644 index 0000000..db4d03b --- /dev/null +++ b/docs/project-spec/walet-frontend-structure.md @@ -0,0 +1,408 @@ +# Frontend Structure Sistem Inventory Walet + +## 1. Tujuan +Dokumen ini menjelaskan struktur frontend yang disarankan untuk aplikasi inventory sarang burung walet berbasis lot, traceability, sorting, partial allocation, dan QR/barcode workflow. + +Target utamanya: +- mudah dibangun bertahap +- mudah dipahami tim frontend +- mobile-friendly untuk flow gudang +- scalable untuk modul traceability dan reporting + +## 2. Rekomendasi Stack +- Next.js / React +- TypeScript +- Tailwind CSS +- shadcn/ui atau komponen serupa +- TanStack Query untuk server state +- Zustand atau Redux Toolkit untuk local workflow state yang kompleks +- React Hook Form + Zod untuk form +- Data table component untuk list/listing besar +- QR scanner berbasis camera API di mobile web + +## 3. Struktur Folder Tingkat Atas +```text +src/ + app/ + components/ + features/ + lib/ + hooks/ + services/ + types/ + store/ + utils/ + config/ +``` + +## 4. Struktur app/routes +```text +src/app/ + login/ + page.tsx + dashboard/ + page.tsx + suppliers/ + page.tsx + new/page.tsx + [id]/page.tsx + [id]/bank/page.tsx + customers/ + page.tsx + new/page.tsx + [id]/page.tsx + [id]/bank/page.tsx + item-types/ + page.tsx + item-grades/ + page.tsx + warehouses/ + page.tsx + [id]/page.tsx + purchases/ + page.tsx + new/page.tsx + [id]/page.tsx + [id]/edit/page.tsx + receipts/ + page.tsx + new/page.tsx + [id]/page.tsx + lots/ + page.tsx + [id]/page.tsx + [id]/trace/page.tsx + sorting/ + page.tsx + new/page.tsx + [id]/page.tsx + regrade/ + new/page.tsx + sales/ + page.tsx + new/page.tsx + [id]/page.tsx + [id]/allocation/page.tsx + [id]/picking/page.tsx + adjustments/ + page.tsx + new/page.tsx + returns/ + sales/page.tsx + purchase/page.tsx + barcode/ + scan/page.tsx + lookup/page.tsx + reports/ + stock-summary/page.tsx + stock-lots/page.tsx + purchases/page.tsx + sales/page.tsx + margins/page.tsx + shrinkage/page.tsx + traceability/page.tsx + settings/ + page.tsx +``` + +## 5. Struktur Features +Setiap domain besar sebaiknya punya feature sendiri. + +```text +src/features/ + auth/ + dashboard/ + suppliers/ + customers/ + item-types/ + item-grades/ + warehouses/ + purchases/ + receipts/ + lots/ + sorting/ + regrade/ + sales/ + allocations/ + adjustments/ + returns/ + barcode/ + reports/ +``` + +## 6. Contoh Isi Tiap Feature +Catatan domain tambahan: +- feature suppliers dan customers perlu mendukung field `bank_name` dan `bank_account_number` +- halaman detail supplier/customer sebaiknya punya section informasi pembayaran + +Contoh feature purchases: + +```text +src/features/purchases/ + api/ + get-purchases.ts + get-purchase-detail.ts + create-purchase.ts + update-purchase.ts + components/ + purchase-form.tsx + purchase-line-table.tsx + purchase-status-badge.tsx + purchase-filter.tsx + hooks/ + use-purchase-form.ts + use-purchases-query.ts + schemas/ + purchase.schema.ts + types/ + purchase.type.ts +``` + +Contoh feature sales: + +```text +src/features/sales/ + api/ + get-sales.ts + get-sales-detail.ts + create-sales.ts + allocate-sales.ts + confirm-picking.ts + components/ + sales-form.tsx + sales-line-table.tsx + allocation-panel.tsx + picking-panel.tsx + sales-summary-card.tsx + hooks/ + use-sales-form.ts + use-allocation.ts + use-picking.ts + schemas/ + sales.schema.ts + types/ + sales.type.ts +``` + +## 7. Shared Components +```text +src/components/ + ui/ + layout/ + app-shell.tsx + sidebar.tsx + topbar.tsx + mobile-nav.tsx + tables/ + data-table.tsx + table-toolbar.tsx + forms/ + form-field.tsx + number-input.tsx + date-picker.tsx + select-async.tsx + status/ + status-badge.tsx + stock-badge.tsx + cards/ + metric-card.tsx + lot-card.tsx + dialogs/ + confirm-dialog.tsx + allocation-dialog.tsx + trace-dialog.tsx + scanners/ + qr-scanner.tsx + barcode-input.tsx +``` + +## 8. Services Layer +Pisahkan akses API dari komponen. + +```text +src/services/ + api-client.ts + auth.service.ts + supplier.service.ts + purchase.service.ts + receipt.service.ts + lot.service.ts + sorting.service.ts + sales.service.ts + adjustment.service.ts + report.service.ts +``` + +## 9. Types Layer +```text +src/types/ + api.ts + auth.ts + supplier.ts + customer.ts + item.ts + warehouse.ts + purchase.ts + receipt.ts + lot.ts + sorting.ts + sales.ts + report.ts +``` + +## 10. State Management +### Server state +Gunakan TanStack Query untuk: +- fetch list +- detail view +- mutation create/update +- invalidate cache + +### Local workflow state +Gunakan Zustand/Redux untuk flow kompleks seperti: +- sales allocation draft +- picking workflow +- sorting result draft +- scanner temporary state + +## 11. Halaman Paling Kritis +### A. Stock Lot List +Kebutuhan: +- filter kuat +- table cepat +- klik ke lot detail +- status warna + +### B. Lot Detail +Kebutuhan: +- identitas lot jelas +- parent-child relation +- movement timeline +- link ke sales/purchase/receipt +- aksi cepat hold/release/regrade/transfer + +### C. Allocation Screen +Kebutuhan: +- sales line summary di atas +- list lot tersedia +- qty allocation editor +- auto allocate FIFO +- kalkulasi total costing real-time +- validasi total qty + +### D. Picking Screen +Kebutuhan: +- scan QR +- tampil lot teralokasi +- input qty picked aktual +- error warning jika beda dari alokasi + +### E. Barcode Lookup Screen +Kebutuhan: +- buka cepat dari scan +- tampil ringkas tapi informatif +- tombol ke lot detail + +## 12. Layout dan UX +### Desktop +Cocok untuk: +- dashboard +- purchasing +- reports +- stock table besar + +### Mobile +Cocok untuk: +- scanning +- receiving +- picking +- transfer lot +- quick lot lookup + +Saran: +- gunakan responsive layout, bukan dua app terpisah dulu +- fokus mobile-first pada layar operasional gudang + +## 13. Form Strategy +### Gunakan form section +Untuk form besar seperti purchase, receipt, sales: +- header section +- detail lines section +- summary section +- actions footer sticky + +### Untuk line items +Gunakan editable table atau repeater row. + +## 14. Component Priorities MVP +Bangun komponen inti dulu: +- app shell +- data table +- form field library +- status badge +- QR scanner +- allocation panel +- movement timeline +- lot summary card + +## 15. Flow Antar Halaman +### Purchase to Receipt +Purchase List -> Purchase Detail -> Create Receipt -> Receipt Detail -> Generate Lots -> Lot Detail + +### Lot to Sorting +Lot List -> Lot Detail -> Create Sorting -> Sorting Result -> Child Lot Detail + +### Sales Flow +Sales List -> Create Sales -> Allocation Screen -> Picking Screen -> Sales Detail + +### Scan Flow +Scan Page -> Lookup Result -> Lot Detail / Picking / Transfer + +## 16. Permissions UI +### Owner +- read mostly all +- no heavy transactional edit by default + +### Purchasing +- purchase pages + +### Warehouse +- receipt, lots, transfer, adjustment, barcode + +### QC +- sorting, regrade + +### Sales +- sales, allocation, picking + +UI harus hide action sesuai role. + +## 17. Frontend API Integration Pattern +Untuk tiap feature: +- query key per list/detail +- mutation untuk create/update/action +- central error handler +- loading skeleton untuk page utama +- toast notification untuk success/error + +## 18. Frontend Deliverable yang Bisa Lanjut Dibuat +Dari struktur ini, next bisa diturunkan menjadi: +- route map detail +- component tree per halaman +- page-by-page wireframe detail +- UI kit token +- frontend task breakdown per sprint + +## 19. Rekomendasi Eksekusi +Urutan frontend yang paling masuk akal: +1. layout + auth +2. master data basic pages +3. purchase & receipt +4. stock lot & lot detail +5. sales & allocation +6. sorting & regrade +7. scanner flow +8. reports + +## 20. Kesimpulan +Frontend sistem walet ini sebaiknya dibangun modular per feature, dengan lot detail dan allocation flow sebagai pusat desain. Fokus utama harus pada kecepatan operasional gudang dan kejelasan traceability, bukan sekadar tampilan tabel stok biasa. \ No newline at end of file diff --git a/docs/project-spec/walet-mock-responses.json b/docs/project-spec/walet-mock-responses.json new file mode 100644 index 0000000..c863f21 --- /dev/null +++ b/docs/project-spec/walet-mock-responses.json @@ -0,0 +1,127 @@ +{ + "login": { + "token": "jwt-token-sample", + "user": { + "id": 5, + "name": "Sales Walet", + "role": "SALES" + } + }, + "lot_detail": { + "id": 4, + "lot_code": "LOT-260428-SUPB-001-S1", + "supplier": { + "id": 2, + "code": "SUP-B", + "name": "Supplier B", + "bank_name": "Mandiri", + "bank_account_number": "9876543210" + }, + "item_type": { + "id": 1, + "code": "JNS-A", + "name": "Jenis A" + }, + "item_grade": { + "id": 1, + "code": "GRD-A", + "name": "Grade A" + }, + "original_qty": 18, + "available_qty": 8, + "reserved_qty": 0, + "unit_cost": 17500000, + "warehouse": { + "id": 1, + "name": "Gudang Pusat" + }, + "location": { + "id": 1, + "name": "Rak A1" + }, + "status": "ACTIVE", + "qr_code_value": "LOT-260428-SUPB-001-S1" + }, + "sales_detail": { + "id": 1, + "sales_no": "SLS-20260428-001", + "customer": { + "id": 1, + "name": "Customer A", + "bank_name": "BRI", + "bank_account_number": "111222333444" + }, + "sales_date": "2026-04-28", + "status": "CONFIRMED", + "lines": [ + { + "id": 1, + "item_type": "Jenis A", + "item_grade": "Grade A", + "qty_sold": 30, + "selling_price": 22000000, + "subtotal": 660000000, + "costing_total": 550000000, + "gross_margin": 110000000, + "allocations": [ + { + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "qty_allocated": 20, + "unit_cost": 18000000, + "total_cost": 360000000 + }, + { + "lot_code": "LOT-260428-SUPB-001-S1", + "supplier": "Supplier B", + "qty_allocated": 10, + "unit_cost": 19000000, + "total_cost": 190000000 + } + ] + } + ] + }, + "barcode_lookup": { + "lot_id": 1, + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "item_type": "Jenis A", + "grade": "Grade A", + "available_qty": 30, + "warehouse": "Gudang Pusat", + "location": "Rak A1", + "status": "ACTIVE", + "trace_summary": { + "purchase_no": "PO-20260428-001", + "used_in_sales": [ + "SLS-20260428-001" + ] + } + }, + "traceability_report": { + "sales_no": "SLS-20260428-001", + "customer": "Customer A", + "lines": [ + { + "item": "Jenis A / Grade A", + "qty": 30, + "sources": [ + { + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "qty": 20, + "purchase_no": "PO-20260428-001" + }, + { + "lot_code": "LOT-260428-SUPB-001-S1", + "supplier": "Supplier B", + "qty": 10, + "purchase_no": "PO-20260428-002", + "parent_lot": "LOT-260428-SUPB-001" + } + ] + } + ] + } +} diff --git a/docs/project-spec/walet-notification-approval-flow.md b/docs/project-spec/walet-notification-approval-flow.md new file mode 100644 index 0000000..707297e --- /dev/null +++ b/docs/project-spec/walet-notification-approval-flow.md @@ -0,0 +1,164 @@ +# Notification dan Approval Flow Sistem Inventory Walet + +## 1. Tujuan +Dokumen ini mendefinisikan event penting yang layak memicu notifikasi atau approval agar kontrol bisnis lebih baik tanpa membuat operasional terlalu lambat. + +## 2. Prinsip +- tidak semua transaksi perlu approval +- approval dipakai untuk aksi berisiko tinggi +- notifikasi dipakai untuk awareness dan respons cepat +- approval tidak boleh menghambat proses gudang rutin yang volumenya tinggi + +## 3. Event Notifikasi yang Direkomendasikan +### A. Purchase Submitted +Notifikasi ke: +- Owner +- Purchasing supervisor jika ada + +Isi: +- nomor pembelian +- supplier +- total nilai +- jumlah line + +### B. Receipt Finalized +Notifikasi ke: +- Owner opsional +- Gudang lead +- Purchasing + +Isi: +- nomor receipt +- supplier +- jumlah lot terbentuk +- ada/tidak selisih + +### C. Large Shrinkage / Adjustment +Notifikasi ke: +- Owner +- Gudang lead +- QC lead jika terkait kualitas + +Trigger contoh: +- shrinkage di atas threshold +- cost impact di atas threshold + +### D. Regrade Event +Notifikasi ke: +- Owner opsional +- QC lead +- Sales opsional jika pengaruh ke order aktif + +### E. Sales Allocation Conflict +Notifikasi ke: +- Sales +- Gudang + +Trigger: +- stok kurang +- lot hold terpilih +- qty dialokasikan bentrok + +### F. Picking Variance +Notifikasi ke: +- Sales +- Gudang + +Trigger: +- qty picked beda dari qty allocated + +### G. Lot Hold / Release +Notifikasi ke: +- Gudang +- QC +- Sales jika lot terkait order aktif + +## 4. Approval Flow yang Direkomendasikan +### A. Purchase Approval +Kapan perlu: +- nilai pembelian di atas limit tertentu +- supplier baru +- harga beli di atas toleransi historis + +Approver: +- Owner +- Purchasing manager + +### B. Adjustment Approval +Kapan perlu: +- qty adjustment besar +- cost impact besar +- reason sensitif seperti loss/missing + +Approver: +- Gudang lead atau Owner + +### C. Regrade Approval +Kapan perlu: +- downgrade signifikan bernilai besar +- lot terkait order customer aktif + +Approver: +- QC lead / Owner + +### D. Sales Override Approval +Kapan perlu: +- manual override dari policy FIFO +- memakai lot non-prioritas +- allow oversell draft exception + +Approver: +- Sales lead / Owner + +## 5. Threshold yang Bisa Diatur +Contoh parameter konfigurasi: +- purchase_approval_amount_threshold +- adjustment_qty_threshold +- adjustment_cost_threshold +- shrinkage_percent_threshold +- regrade_qty_threshold +- manual_override_requires_approval + +## 6. Status Workflow Tambahan yang Bisa Dipakai +### Purchase +- DRAFT +- PENDING_APPROVAL +- SUBMITTED +- APPROVED +- CANCELLED + +### Adjustment +- DRAFT +- PENDING_APPROVAL +- APPROVED +- POSTED +- REJECTED + +### Regrade +- DRAFT +- PENDING_APPROVAL +- APPROVED +- EXECUTED +- REJECTED + +## 7. UI Flow Sederhana +### Purchase approval +- purchasing submit purchase +- jika melewati threshold -> status PENDING_APPROVAL +- owner approve/reject +- jika approve -> lanjut SUBMITTED + +### Adjustment approval +- gudang buat adjustment +- jika kecil -> langsung POSTED +- jika besar -> PENDING_APPROVAL +- approver setujui -> POSTED + +## 8. Notifikasi Channel +MVP cukup dukung: +- in-app notification +- email optional +- WhatsApp internal opsional nanti kalau dibutuhkan + +## 9. Kesimpulan +Approval dan notifikasi harus fokus ke transaksi berisiko tinggi, bukan semua transaksi. Dengan begitu sistem tetap lincah untuk operasional harian, tapi owner tetap punya kontrol di titik-titik penting. \ No newline at end of file diff --git a/docs/project-spec/walet-openapi.yaml b/docs/project-spec/walet-openapi.yaml new file mode 100644 index 0000000..8e8e144 --- /dev/null +++ b/docs/project-spec/walet-openapi.yaml @@ -0,0 +1,722 @@ +openapi: 3.0.3 +info: + title: Walet Inventory API + version: 1.0.0 + description: API untuk sistem inventory sarang burung walet berbasis lot, traceability, sorting, dan partial sales allocation. +servers: + - url: /api/v1 +paths: + /auth/login: + post: + summary: Login + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + password: + type: string + responses: + '200': + description: Login success + /suppliers: + get: + summary: List suppliers + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: { type: integer } + code: { type: string } + name: { type: string } + phone: { type: string } + email: { type: string } + bank_name: { type: string } + bank_account_number: { type: string } + address: { type: string } + post: + summary: Create supplier + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + name: + type: string + phone: + type: string + email: + type: string + bank_name: + type: string + bank_account_number: + type: string + address: + type: string + responses: + '201': + description: Created + /customers: + get: + summary: List customers + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: { type: integer } + code: { type: string } + name: { type: string } + phone: { type: string } + email: { type: string } + bank_name: { type: string } + bank_account_number: { type: string } + address: { type: string } + post: + summary: Create customer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + name: + type: string + phone: + type: string + email: + type: string + bank_name: + type: string + bank_account_number: + type: string + address: + type: string + responses: + '201': + description: Created + /item-types: + get: + summary: List item types + responses: + '200': + description: OK + /item-grades: + get: + summary: List item grades + responses: + '200': + description: OK + /warehouses: + get: + summary: List warehouses + responses: + '200': + description: OK + /purchases: + get: + summary: List purchases + parameters: + - in: query + name: supplier_id + schema: { type: integer } + - in: query + name: status + schema: { type: string } + responses: + '200': + description: OK + post: + summary: Create purchase + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePurchaseRequest' + responses: + '201': + description: Created + /purchases/{id}: + get: + summary: Purchase detail + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: OK + /receipts: + get: + summary: List receipts + responses: + '200': + description: OK + post: + summary: Create receipt + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateReceiptRequest' + responses: + '201': + description: Created + /receipts/{id}/generate-lots: + post: + summary: Generate lots from receipt + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Lots generated + /lots: + get: + summary: List inventory lots + parameters: + - in: query + name: supplier_id + schema: { type: integer } + - in: query + name: item_type_id + schema: { type: integer } + - in: query + name: item_grade_id + schema: { type: integer } + - in: query + name: warehouse_id + schema: { type: integer } + - in: query + name: status + schema: { type: string } + responses: + '200': + description: OK + /lots/{id}: + get: + summary: Lot detail + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: OK + /lots/{id}/movements: + get: + summary: Lot movement history + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: OK + /lots/{id}/trace: + get: + summary: Lot traceability detail + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: OK + /lots/{id}/hold: + post: + summary: Hold lot + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Lot held + /lots/{id}/release: + post: + summary: Release lot + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Lot released + /lots/{id}/transfer: + post: + summary: Transfer lot + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + to_warehouse_id: + type: integer + to_location_id: + type: integer + qty: + type: number + notes: + type: string + responses: + '200': + description: Transfer success + /sorting-sessions: + get: + summary: List sorting sessions + responses: + '200': + description: OK + post: + summary: Create sorting session + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSortingSessionRequest' + responses: + '201': + description: Created + /lots/{id}/regrade: + post: + summary: Regrade lot + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [target_grade_id, qty, reason_id] + properties: + target_grade_id: + type: integer + qty: + type: number + reason_id: + type: integer + notes: + type: string + responses: + '200': + description: Regrade success + /sales: + get: + summary: List sales + responses: + '200': + description: OK + post: + summary: Create sales order + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSalesRequest' + responses: + '201': + description: Created + /sales/{id}: + get: + summary: Sales detail + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: OK + /sales/{id}/allocate: + post: + summary: Manual allocate lots to sales lines + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AllocateSalesRequest' + responses: + '200': + description: Allocation success + /sales/{id}/auto-allocate: + post: + summary: Auto allocate by policy + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + policy: + type: string + example: FIFO + responses: + '200': + description: Auto allocation success + /sales/{id}/confirm-picking: + post: + summary: Confirm picked quantities + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmPickingRequest' + responses: + '200': + description: Picking confirmed + /stock-adjustments: + get: + summary: List stock adjustments + responses: + '200': + description: OK + post: + summary: Create stock adjustment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateStockAdjustmentRequest' + responses: + '201': + description: Created + /sales-returns: + post: + summary: Create sales return + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSalesReturnRequest' + responses: + '201': + description: Created + /purchase-returns: + post: + summary: Create purchase return + responses: + '201': + description: Created + /barcode/lookup/{value}: + get: + summary: Lookup lot by barcode or QR value + parameters: + - in: path + name: value + required: true + schema: + type: string + responses: + '200': + description: OK + /reports/stock-summary: + get: + summary: Stock summary report + responses: + '200': + description: OK + /reports/stock-lots: + get: + summary: Stock lots report + responses: + '200': + description: OK + /reports/purchases: + get: + summary: Purchases report + responses: + '200': + description: OK + /reports/sales: + get: + summary: Sales report + responses: + '200': + description: OK + /reports/margins: + get: + summary: Margin report + responses: + '200': + description: OK + /reports/shrinkage: + get: + summary: Shrinkage report + responses: + '200': + description: OK + /reports/supplier-quality: + get: + summary: Supplier quality report + responses: + '200': + description: OK + /reports/traceability: + get: + summary: Traceability report + parameters: + - in: query + name: sales_id + schema: { type: integer } + - in: query + name: lot_id + schema: { type: integer } + responses: + '200': + description: OK +components: + parameters: + IdParam: + in: path + name: id + required: true + schema: + type: integer + schemas: + CreatePurchaseRequest: + type: object + required: [supplier_id, purchase_date, lines] + properties: + supplier_id: + type: integer + purchase_date: + type: string + format: date + supplier_invoice_no: + type: string + notes: + type: string + lines: + type: array + items: + type: object + required: [item_type_id, qty_ordered, unit_id, unit_price, classification_status] + properties: + item_type_id: + type: integer + item_grade_id: + type: integer + nullable: true + qty_ordered: + type: number + unit_id: + type: integer + unit_price: + type: number + classification_status: + type: string + CreateReceiptRequest: + type: object + required: [purchase_id, supplier_id, receipt_date, lines] + properties: + purchase_id: + type: integer + supplier_id: + type: integer + receipt_date: + type: string + format: date + notes: + type: string + lines: + type: array + items: + type: object + required: [purchase_line_id, item_type_id, qty_received, qty_accepted, unit_id, unit_cost, warehouse_id] + properties: + purchase_line_id: + type: integer + item_type_id: + type: integer + item_grade_id: + type: integer + nullable: true + qty_received: + type: number + qty_accepted: + type: number + qty_rejected: + type: number + unit_id: + type: integer + unit_cost: + type: number + warehouse_id: + type: integer + warehouse_location_id: + type: integer + CreateSortingSessionRequest: + type: object + required: [source_lot_id, sorting_date, input_qty, results] + properties: + source_lot_id: + type: integer + sorting_date: + type: string + format: date-time + input_qty: + type: number + shrinkage_qty: + type: number + notes: + type: string + results: + type: array + items: + type: object + required: [item_type_id, item_grade_id, qty_result, unit_cost] + properties: + item_type_id: + type: integer + item_grade_id: + type: integer + qty_result: + type: number + unit_cost: + type: number + CreateSalesRequest: + type: object + required: [customer_id, sales_date, lines] + properties: + customer_id: + type: integer + sales_date: + type: string + format: date + notes: + type: string + lines: + type: array + items: + type: object + required: [item_type_id, item_grade_id, qty_sold, unit_id, selling_price] + properties: + item_type_id: + type: integer + item_grade_id: + type: integer + qty_sold: + type: number + unit_id: + type: integer + selling_price: + type: number + AllocateSalesRequest: + type: object + required: [lines] + properties: + lines: + type: array + items: + type: object + required: [sales_line_id, allocations] + properties: + sales_line_id: + type: integer + allocations: + type: array + items: + type: object + required: [inventory_lot_id, qty_allocated] + properties: + inventory_lot_id: + type: integer + qty_allocated: + type: number + ConfirmPickingRequest: + type: object + required: [lines] + properties: + lines: + type: array + items: + type: object + required: [sales_line_id, picked_allocations] + properties: + sales_line_id: + type: integer + picked_allocations: + type: array + items: + type: object + required: [inventory_lot_id, qty_picked] + properties: + inventory_lot_id: + type: integer + qty_picked: + type: number + CreateStockAdjustmentRequest: + type: object + required: [inventory_lot_id, adjustment_type, reason_id, qty_change] + properties: + inventory_lot_id: + type: integer + adjustment_type: + type: string + reason_id: + type: integer + qty_change: + type: number + notes: + type: string + CreateSalesReturnRequest: + type: object + required: [sales_id, customer_id, return_date, lines] + properties: + sales_id: + type: integer + customer_id: + type: integer + return_date: + type: string + format: date + lines: + type: array + items: + type: object + required: [sales_line_id, item_type_id, item_grade_id, qty_returned] + properties: + sales_line_id: + type: integer + inventory_lot_id: + type: integer + item_type_id: + type: integer + item_grade_id: + type: integer + qty_returned: + type: number + return_condition: + type: string + resolution: + type: string + notes: + type: string diff --git a/docs/project-spec/walet-postman-collection.json b/docs/project-spec/walet-postman-collection.json new file mode 100644 index 0000000..2cd3f33 --- /dev/null +++ b/docs/project-spec/walet-postman-collection.json @@ -0,0 +1,363 @@ +{ + "info": { + "name": "Walet Inventory API", + "description": "Postman collection untuk sistem inventory sarang burung walet berbasis lot, traceability, sorting, dan allocation.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000/api/v1" + }, + { + "key": "token", + "value": "" + } + ], + "item": [ + { + "name": "Auth", + "item": [ + { + "name": "Login", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/auth/login", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"admin@example.com\",\n \"password\": \"secret\"\n}" + } + } + } + ] + }, + { + "name": "Master Data", + "item": [ + { + "name": "List Suppliers", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/suppliers" + } + }, + { + "name": "Create Supplier", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/suppliers", + "body": { + "mode": "raw", + "raw": "{\n \"code\": \"SUP-C\",\n \"name\": \"Supplier C\",\n \"phone\": \"081310000003\",\n \"email\": \"sup-c@walet.local\",\n \"bank_name\": \"BCA\",\n \"bank_account_number\": \"333444555666\",\n \"address\": \"Semarang\"\n}" + } + } + }, + { + "name": "List Customers", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/customers" + } + }, + { + "name": "Create Customer", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/customers", + "body": { + "mode": "raw", + "raw": "{\n \"code\": \"CUST-C\",\n \"name\": \"Customer C\",\n \"phone\": \"081320000003\",\n \"email\": \"cust-c@walet.local\",\n \"bank_name\": \"Mandiri\",\n \"bank_account_number\": \"777888999000\",\n \"address\": \"Yogyakarta\"\n}" + } + } + }, + { + "name": "List Item Types", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/item-types" + } + }, + { + "name": "List Item Grades", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/item-grades" + } + } + ] + }, + { + "name": "Purchasing", + "item": [ + { + "name": "Create Purchase", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/purchases", + "body": { + "mode": "raw", + "raw": "{\n \"supplier_id\": 1,\n \"purchase_date\": \"2026-04-28\",\n \"supplier_invoice_no\": \"INV-8891\",\n \"notes\": \"Pembelian campuran\",\n \"lines\": [\n {\n \"item_type_id\": 1,\n \"item_grade_id\": 1,\n \"qty_ordered\": 50,\n \"unit_id\": 1,\n \"unit_price\": 18000000,\n \"classification_status\": \"FINAL\"\n },\n {\n \"item_type_id\": 1,\n \"item_grade_id\": null,\n \"qty_ordered\": 40,\n \"unit_id\": 1,\n \"unit_price\": 17500000,\n \"classification_status\": \"PROVISIONAL\"\n }\n ]\n}" + } + } + }, + { + "name": "List Purchases", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/purchases" + } + } + ] + }, + { + "name": "Receiving", + "item": [ + { + "name": "Create Receipt", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/receipts", + "body": { + "mode": "raw", + "raw": "{\n \"purchase_id\": 1,\n \"supplier_id\": 1,\n \"receipt_date\": \"2026-04-28\",\n \"notes\": \"Barang diterima baik\",\n \"lines\": [\n {\n \"purchase_line_id\": 1,\n \"item_type_id\": 1,\n \"item_grade_id\": 1,\n \"qty_received\": 50,\n \"qty_accepted\": 50,\n \"qty_rejected\": 0,\n \"unit_id\": 1,\n \"unit_cost\": 18000000,\n \"warehouse_id\": 1,\n \"warehouse_location_id\": 1\n }\n ]\n}" + } + } + }, + { + "name": "Generate Lots", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/receipts/1/generate-lots" + } + } + ] + }, + { + "name": "Inventory Lots", + "item": [ + { + "name": "List Lots", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/lots" + } + }, + { + "name": "Lot Detail", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/lots/1" + } + }, + { + "name": "Lot Trace", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/lots/1/trace" + } + } + ] + }, + { + "name": "Sorting & Regrade", + "item": [ + { + "name": "Create Sorting Session", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/sorting-sessions", + "body": { + "mode": "raw", + "raw": "{\n \"source_lot_id\": 10,\n \"sorting_date\": \"2026-04-28T15:00:00Z\",\n \"input_qty\": 40,\n \"shrinkage_qty\": 3,\n \"notes\": \"Sortasi batch campuran\",\n \"results\": [\n {\n \"item_type_id\": 1,\n \"item_grade_id\": 1,\n \"qty_result\": 18,\n \"unit_cost\": 17500000\n },\n {\n \"item_type_id\": 1,\n \"item_grade_id\": 2,\n \"qty_result\": 12,\n \"unit_cost\": 17500000\n }\n ]\n}" + } + } + }, + { + "name": "Regrade Lot", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/lots/1/regrade", + "body": { + "mode": "raw", + "raw": "{\n \"target_grade_id\": 2,\n \"qty\": 5,\n \"reason_id\": 3,\n \"notes\": \"Turun grade setelah QC\"\n}" + } + } + } + ] + }, + { + "name": "Sales", + "item": [ + { + "name": "Create Sales Order", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/sales", + "body": { + "mode": "raw", + "raw": "{\n \"customer_id\": 1,\n \"sales_date\": \"2026-04-28\",\n \"notes\": \"Order customer X\",\n \"lines\": [\n {\n \"item_type_id\": 1,\n \"item_grade_id\": 1,\n \"qty_sold\": 30,\n \"unit_id\": 1,\n \"selling_price\": 22000000\n }\n ]\n}" + } + } + }, + { + "name": "Allocate Sales Lots", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/sales/1/allocate", + "body": { + "mode": "raw", + "raw": "{\n \"lines\": [\n {\n \"sales_line_id\": 1,\n \"allocations\": [\n {\n \"inventory_lot_id\": 1,\n \"qty_allocated\": 20\n },\n {\n \"inventory_lot_id\": 4,\n \"qty_allocated\": 10\n }\n ]\n }\n ]\n}" + } + } + }, + { + "name": "Auto Allocate Sales", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/sales/1/auto-allocate", + "body": { + "mode": "raw", + "raw": "{\n \"policy\": \"FIFO\"\n}" + } + } + } + ] + }, + { + "name": "Adjustment & Returns", + "item": [ + { + "name": "Create Stock Adjustment", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/stock-adjustments", + "body": { + "mode": "raw", + "raw": "{\n \"inventory_lot_id\": 11,\n \"adjustment_type\": \"SHRINKAGE\",\n \"reason_id\": 1,\n \"qty_change\": -1.2,\n \"notes\": \"Selisih opname\"\n}" + } + } + }, + { + "name": "Create Sales Return", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{baseUrl}}/sales-returns", + "body": { + "mode": "raw", + "raw": "{\n \"sales_id\": 1,\n \"customer_id\": 1,\n \"return_date\": \"2026-04-29\",\n \"lines\": [\n {\n \"sales_line_id\": 1,\n \"inventory_lot_id\": 1,\n \"item_type_id\": 1,\n \"item_grade_id\": 1,\n \"qty_returned\": 2,\n \"return_condition\": \"GOOD\",\n \"resolution\": \"RESTOCK\"\n }\n ]\n}" + } + } + } + ] + }, + { + "name": "Barcode & Reports", + "item": [ + { + "name": "Barcode Lookup", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/barcode/lookup/LOT-260428-SUPA-001" + } + }, + { + "name": "Stock Summary Report", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/reports/stock-summary" + } + }, + { + "name": "Traceability Report by Sales", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": "{{baseUrl}}/reports/traceability?sales_id=1" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/project-spec/walet-postman-environment.json b/docs/project-spec/walet-postman-environment.json new file mode 100644 index 0000000..7168d8f --- /dev/null +++ b/docs/project-spec/walet-postman-environment.json @@ -0,0 +1,105 @@ +{ + "id": "9d2b4d2e-0b58-4f8c-9c0e-walet-env-001", + "name": "Walet Inventory Local", + "values": [ + { + "key": "baseUrl", + "value": "http://localhost:3000/api/v1", + "type": "text", + "enabled": true + }, + { + "key": "token", + "value": "", + "type": "text", + "enabled": true + }, + { + "key": "purchaseId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "receiptId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "lotId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "salesId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "salesLineId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "supplierId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "customerId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "warehouseId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "warehouseLocationId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "itemTypeId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "itemGradeId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "unitId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "reasonId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "barcodeValue", + "value": "LOT-260428-SUPA-001", + "type": "text", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-04-28T16:13:00+07:00", + "_postman_exported_using": "OpenClaw Joni" +} \ No newline at end of file diff --git a/docs/project-spec/walet-prd.html b/docs/project-spec/walet-prd.html new file mode 100644 index 0000000..44ebda7 --- /dev/null +++ b/docs/project-spec/walet-prd.html @@ -0,0 +1,293 @@ +
# PRD Sistem Inventory Sarang Burung Walet
+
+## 1. Ringkasan Produk
+Produk ini adalah sistem inventory sarang burung walet berbasis lot atau batch untuk bisnis perdagangan yang dimulai dari pembelian, bukan produksi. Sistem harus mampu menangani pembelian multi jenis dan multi grade, sortasi internal, penyimpanan lot, penjualan parsial dari banyak lot, costing berbasis allocation nyata, penyusutan per lot, dan traceability penuh dengan dukungan barcode atau QR code.
+
+## 2. Latar Belakang Masalah
+Bisnis sarang burung walet memiliki kompleksitas yang tidak bisa ditangani inventory biasa karena:
+
+- satu pembelian bisa berisi banyak jenis dan grade
+- barang dari supplier bisa perlu diverifikasi atau disortir ulang
+- stok akhir harus ditrack per lot
+- satu penjualan bisa mengambil stok dari beberapa lot berbeda
+- HPP penjualan harus mengikuti lot yang benar-benar dipakai
+- penyusutan, kerusakan, regrade, dan retur harus tetap bisa ditelusuri
+- owner perlu tahu asal barang, margin, dan performa supplier
+
+## 3. Tujuan Produk
+Tujuan produk ini:
+
+- membuat pencatatan pembelian dan penerimaan barang lebih rapi
+- menjaga traceability dari supplier sampai customer
+- menghitung costing dan margin secara akurat
+- mengurangi kehilangan histori lot
+- memudahkan proses sortasi dan regrade
+- memberi visibilitas stok per jenis-grade dan per lot
+- mendukung operasional gudang dengan barcode atau QR scan
+
+## 4. Ruang Lingkup
+### In Scope
+- master data supplier, customer, jenis, grade, gudang, user
+- pembelian dan penerimaan barang
+- pembuatan lot/batch
+- sortasi / klasifikasi / regrade
+- stok summary dan stok lot
+- sales order dan allocation lot
+- movement ledger
+- shrinkage / adjustment / damage / return
+- traceability report
+- barcode / QR label generation dan lookup
+
+### Out of Scope untuk MVP
+- akuntansi umum penuh
+- integrasi marketplace
+- integrasi IoT
+- forecasting AI
+- multi currency kompleks
+- mobile app native terpisah, jika web mobile sudah cukup
+
+## 5. User dan Role
+### Owner / Management
+Kebutuhan:
+- melihat dashboard
+- memantau stok, nilai inventory, margin, susut
+- melihat traceability dan performa supplier
+
+### Admin Purchasing
+Kebutuhan:
+- membuat pembelian
+- melihat histori pembelian dan harga beli
+
+### Admin Gudang
+Kebutuhan:
+- menerima barang
+- membuat dan memindahkan lot
+- opname dan adjustment
+- cetak label QR/barcode
+
+### Tim Sortasi / QC
+Kebutuhan:
+- melakukan sortasi
+- mengubah satu lot menjadi beberapa lot hasil
+- mencatat susut/reject/regrade
+
+### Admin Sales
+Kebutuhan:
+- membuat sales order
+- melihat stok tersedia
+- memilih atau menyesuaikan alokasi lot
+- memproses picking dan invoice
+
+## 6. Problem Statement
+Bagaimana membuat sistem yang mampu mengelola stok sarang walet secara detail sampai level lot, sambil tetap mudah dipakai untuk operasional harian, serta menjaga costing dan traceability tetap akurat walaupun penjualan diambil dari campuran beberapa lot?
+
+## 7. Product Principles
+- lot adalah unit traceability utama
+- stock summary hanya agregasi, bukan sumber kebenaran utama
+- semua perubahan stok harus masuk movement ledger
+- costing penjualan harus berbasis alokasi lot nyata
+- barcode atau QR membantu scan, tetapi sumber trace tetap pada model data
+- sistem harus mendukung override manual jika operasional lapangan membutuhkannya
+
+## 8. Business Flow Summary
+1. master setup
+2. create purchase
+3. receive goods
+4. create lots
+5. sort or verify if needed
+6. store and move lots
+7. sell and allocate lots
+8. ship and confirm quantity
+9. handle returns, shrinkage, regrade, adjustment
+10. monitor reports and traceability
+
+## 9. Functional Requirements
+
+### 9.1 Master Data
+Sistem harus menyediakan CRUD untuk:
+- suppliers, termasuk data nomor rekening dan nama bank
+- customers, termasuk data nomor rekening dan nama bank
+- item types
+- item grades
+- warehouses
+- warehouse locations
+- users
+- roles
+- adjustment reasons
+- units
+
+### 9.2 Purchasing
+Sistem harus bisa:
+- membuat dokumen pembelian
+- menyimpan banyak line dalam satu pembelian
+- menyimpan jenis, grade, qty, harga beli, subtotal
+- menandai line apakah final atau provisional
+- menyimpan nomor invoice supplier
+- menampilkan histori harga beli per jenis-grade
+
+### 9.3 Receiving
+Sistem harus bisa:
+- menerima barang dari pembelian
+- membandingkan qty ordered vs qty received
+- mencatat qty accepted dan rejected
+- membuat lot per receipt line atau per hasil penerimaan
+- menyimpan cost lot
+- mencetak QR/barcode label
+
+### 9.4 Inventory Lot
+Sistem harus bisa:
+- menampilkan stok per lot
+- menampilkan stok summary per jenis-grade
+- menandai lot aktif, hold, closed, rejected
+- menyimpan parent-child lot
+- menyimpan gudang dan lokasi simpan
+- menampilkan aging lot
+
+### 9.5 Sorting / Classification / Regrade
+Sistem harus bisa:
+- membuat sorting session dari source lot
+- menghasilkan banyak lot baru dari satu lot asal
+- mencatat qty input, qty output, dan shrinkage
+- melakukan regrade dari satu grade ke grade lain
+- mencatat reject
+
+### 9.6 Sales
+Sistem harus bisa:
+- membuat sales order
+- menambah sales lines per jenis-grade
+- menampilkan stok tersedia
+- mengalokasikan kebutuhan sales line ke satu atau banyak lot
+- mendukung alokasi otomatis dan manual
+- menghitung costing_total dan gross_margin
+
+### 9.7 Allocation Engine
+Sistem harus bisa:
+- memberi rekomendasi lot berdasarkan FIFO
+- memungkinkan override manual
+- menghitung total cost dari setiap allocation
+- memastikan qty allocation tidak melebihi stok available
+
+### 9.8 Inventory Movement Ledger
+Sistem harus mencatat semua mutasi:
+- receipt in
+- sorting out/in
+- sales out
+- return in/out
+- transfer in/out
+- shrinkage
+- adjustment
+- regrade out/in
+
+### 9.9 Returns & Adjustments
+Sistem harus bisa:
+- mencatat retur pembelian
+- mencatat retur penjualan
+- mencatat adjustment stok
+- mencatat damage dan shrinkage
+- mengaitkan semua ke lot tertentu
+
+### 9.10 Traceability
+Sistem harus menyediakan:
+- backward trace, dari sales ke supplier
+- forward trace, dari supplier/lot ke customer
+- histori proses lot, termasuk sorting, regrade, transfer, shrinkage, sales
+
+### 9.11 Barcode / QR
+Sistem harus bisa:
+- generate label QR/barcode untuk lot
+- print ulang label
+- scan lot untuk lookup data
+- scan saat transfer, picking, dan stock opname
+
+## 10. Non-Functional Requirements
+- web-based dan mobile-friendly
+- audit trail untuk transaksi penting
+- performa query stok dan lot harus cepat
+- akses berbasis role
+- histori tidak boleh hilang walau lot sudah closed
+- data harus siap diekspor
+
+## 11. Costing Rules
+- unit cost utama disimpan per lot
+- costing sales line dihitung dari gabungan allocation nyata
+- default allocation policy adalah FIFO
+- sistem boleh mendukung FEFO/manual/hybrid
+- shrinkage mengurangi qty dan memengaruhi balance lot
+- regrade harus memindahkan qty dan histori cost dengan jelas
+
+## 12. Traceability Rules
+### Backward Trace
+sales -> sales_allocations -> inventory_lots -> receipt_lines -> purchase_lines -> purchases -> suppliers
+
+### Forward Trace
+suppliers -> purchases -> inventory_lots -> sales_allocations -> sales_lines -> sales -> customers
+
+### Process Trace
+inventory_lots -> sorting_sessions -> sorting_results -> stock_adjustments -> inventory_movements
+
+## 13. Key Screens
+- Dashboard
+- Purchase List
+- Purchase Detail/Form
+- Receipt Form
+- Receipt Detail
+- Stock Summary
+- Stock Lot List
+- Lot Detail
+- Sorting Session Form
+- Sales Form
+- Allocation Screen
+- Picking Screen
+- Adjustment Form
+- Regrade Form
+- Barcode Lookup
+- Reports
+
+## 14. KPI / Success Metrics
+- akurasi stok per lot
+- kecepatan trace dari penjualan ke supplier
+- penurunan selisih opname
+- akurasi HPP penjualan
+- kecepatan receiving dan picking
+- visibilitas susut per lot dan supplier
+
+## 15. MVP Recommendation
+### Phase 1
+- master data
+- purchasing
+- receiving
+- inventory lot
+- stock summary
+- sales
+- sales allocation
+- movement ledger
+- QR label dasar
+
+### Phase 2
+- sorting
+- regrade
+- stock adjustment detail
+- transfer gudang
+- stock opname scan
+
+### Phase 3
+- analytics supplier quality
+- analytics margin dan shrinkage
+- mobile scanner workflow lebih maju
+- smarter allocation engine
+
+## 16. Risiko Implementasi
+- user ingin proses cepat tetapi model data kompleks
+- salah desain costing akan merusak laporan margin
+- jika lot discipline tidak dijaga, traceability rusak
+- jika QR hanya kosmetik tanpa lot discipline, manfaatnya kecil
+
+## 17. Keputusan Produk yang Direkomendasikan
+- jadikan lot sebagai sumber kebenaran traceability
+- pakai stock summary hanya sebagai view agregat
+- gunakan FIFO default dengan override manual
+- pakai QR code di level lot
+- prioritaskan lot detail, allocation, dan movement ledger di fase awal
+
+## 18. Kesimpulan
+Produk ini adalah sistem inventory walet yang berorientasi pada lot, costing akurat, dan traceability penuh. Dengan desain ini, bisnis dapat mengelola pembelian multi jenis-grade, sortasi, penjualan campuran, susut, dan audit histori secara jauh lebih aman dibanding inventory biasa.
\ No newline at end of file diff --git a/docs/project-spec/walet-prd.md b/docs/project-spec/walet-prd.md new file mode 100644 index 0000000..165f525 --- /dev/null +++ b/docs/project-spec/walet-prd.md @@ -0,0 +1,293 @@ +# PRD Sistem Inventory Sarang Burung Walet + +## 1. Ringkasan Produk +Produk ini adalah sistem inventory sarang burung walet berbasis lot atau batch untuk bisnis perdagangan yang dimulai dari pembelian, bukan produksi. Sistem harus mampu menangani pembelian multi jenis dan multi grade, sortasi internal, penyimpanan lot, penjualan parsial dari banyak lot, costing berbasis allocation nyata, penyusutan per lot, dan traceability penuh dengan dukungan barcode atau QR code. + +## 2. Latar Belakang Masalah +Bisnis sarang burung walet memiliki kompleksitas yang tidak bisa ditangani inventory biasa karena: + +- satu pembelian bisa berisi banyak jenis dan grade +- barang dari supplier bisa perlu diverifikasi atau disortir ulang +- stok akhir harus ditrack per lot +- satu penjualan bisa mengambil stok dari beberapa lot berbeda +- HPP penjualan harus mengikuti lot yang benar-benar dipakai +- penyusutan, kerusakan, regrade, dan retur harus tetap bisa ditelusuri +- owner perlu tahu asal barang, margin, dan performa supplier + +## 3. Tujuan Produk +Tujuan produk ini: + +- membuat pencatatan pembelian dan penerimaan barang lebih rapi +- menjaga traceability dari supplier sampai customer +- menghitung costing dan margin secara akurat +- mengurangi kehilangan histori lot +- memudahkan proses sortasi dan regrade +- memberi visibilitas stok per jenis-grade dan per lot +- mendukung operasional gudang dengan barcode atau QR scan + +## 4. Ruang Lingkup +### In Scope +- master data supplier, customer, jenis, grade, gudang, user +- pembelian dan penerimaan barang +- pembuatan lot/batch +- sortasi / klasifikasi / regrade +- stok summary dan stok lot +- sales order dan allocation lot +- movement ledger +- shrinkage / adjustment / damage / return +- traceability report +- barcode / QR label generation dan lookup + +### Out of Scope untuk MVP +- akuntansi umum penuh +- integrasi marketplace +- integrasi IoT +- forecasting AI +- multi currency kompleks +- mobile app native terpisah, jika web mobile sudah cukup + +## 5. User dan Role +### Owner / Management +Kebutuhan: +- melihat dashboard +- memantau stok, nilai inventory, margin, susut +- melihat traceability dan performa supplier + +### Admin Purchasing +Kebutuhan: +- membuat pembelian +- melihat histori pembelian dan harga beli + +### Admin Gudang +Kebutuhan: +- menerima barang +- membuat dan memindahkan lot +- opname dan adjustment +- cetak label QR/barcode + +### Tim Sortasi / QC +Kebutuhan: +- melakukan sortasi +- mengubah satu lot menjadi beberapa lot hasil +- mencatat susut/reject/regrade + +### Admin Sales +Kebutuhan: +- membuat sales order +- melihat stok tersedia +- memilih atau menyesuaikan alokasi lot +- memproses picking dan invoice + +## 6. Problem Statement +Bagaimana membuat sistem yang mampu mengelola stok sarang walet secara detail sampai level lot, sambil tetap mudah dipakai untuk operasional harian, serta menjaga costing dan traceability tetap akurat walaupun penjualan diambil dari campuran beberapa lot? + +## 7. Product Principles +- lot adalah unit traceability utama +- stock summary hanya agregasi, bukan sumber kebenaran utama +- semua perubahan stok harus masuk movement ledger +- costing penjualan harus berbasis alokasi lot nyata +- barcode atau QR membantu scan, tetapi sumber trace tetap pada model data +- sistem harus mendukung override manual jika operasional lapangan membutuhkannya + +## 8. Business Flow Summary +1. master setup +2. create purchase +3. receive goods +4. create lots +5. sort or verify if needed +6. store and move lots +7. sell and allocate lots +8. ship and confirm quantity +9. handle returns, shrinkage, regrade, adjustment +10. monitor reports and traceability + +## 9. Functional Requirements + +### 9.1 Master Data +Sistem harus menyediakan CRUD untuk: +- suppliers, termasuk data nomor rekening dan nama bank +- customers, termasuk data nomor rekening dan nama bank +- item types +- item grades +- warehouses +- warehouse locations +- users +- roles +- adjustment reasons +- units + +### 9.2 Purchasing +Sistem harus bisa: +- membuat dokumen pembelian +- menyimpan banyak line dalam satu pembelian +- menyimpan jenis, grade, qty, harga beli, subtotal +- menandai line apakah final atau provisional +- menyimpan nomor invoice supplier +- menampilkan histori harga beli per jenis-grade + +### 9.3 Receiving +Sistem harus bisa: +- menerima barang dari pembelian +- membandingkan qty ordered vs qty received +- mencatat qty accepted dan rejected +- membuat lot per receipt line atau per hasil penerimaan +- menyimpan cost lot +- mencetak QR/barcode label + +### 9.4 Inventory Lot +Sistem harus bisa: +- menampilkan stok per lot +- menampilkan stok summary per jenis-grade +- menandai lot aktif, hold, closed, rejected +- menyimpan parent-child lot +- menyimpan gudang dan lokasi simpan +- menampilkan aging lot + +### 9.5 Sorting / Classification / Regrade +Sistem harus bisa: +- membuat sorting session dari source lot +- menghasilkan banyak lot baru dari satu lot asal +- mencatat qty input, qty output, dan shrinkage +- melakukan regrade dari satu grade ke grade lain +- mencatat reject + +### 9.6 Sales +Sistem harus bisa: +- membuat sales order +- menambah sales lines per jenis-grade +- menampilkan stok tersedia +- mengalokasikan kebutuhan sales line ke satu atau banyak lot +- mendukung alokasi otomatis dan manual +- menghitung costing_total dan gross_margin + +### 9.7 Allocation Engine +Sistem harus bisa: +- memberi rekomendasi lot berdasarkan FIFO +- memungkinkan override manual +- menghitung total cost dari setiap allocation +- memastikan qty allocation tidak melebihi stok available + +### 9.8 Inventory Movement Ledger +Sistem harus mencatat semua mutasi: +- receipt in +- sorting out/in +- sales out +- return in/out +- transfer in/out +- shrinkage +- adjustment +- regrade out/in + +### 9.9 Returns & Adjustments +Sistem harus bisa: +- mencatat retur pembelian +- mencatat retur penjualan +- mencatat adjustment stok +- mencatat damage dan shrinkage +- mengaitkan semua ke lot tertentu + +### 9.10 Traceability +Sistem harus menyediakan: +- backward trace, dari sales ke supplier +- forward trace, dari supplier/lot ke customer +- histori proses lot, termasuk sorting, regrade, transfer, shrinkage, sales + +### 9.11 Barcode / QR +Sistem harus bisa: +- generate label QR/barcode untuk lot +- print ulang label +- scan lot untuk lookup data +- scan saat transfer, picking, dan stock opname + +## 10. Non-Functional Requirements +- web-based dan mobile-friendly +- audit trail untuk transaksi penting +- performa query stok dan lot harus cepat +- akses berbasis role +- histori tidak boleh hilang walau lot sudah closed +- data harus siap diekspor + +## 11. Costing Rules +- unit cost utama disimpan per lot +- costing sales line dihitung dari gabungan allocation nyata +- default allocation policy adalah FIFO +- sistem boleh mendukung FEFO/manual/hybrid +- shrinkage mengurangi qty dan memengaruhi balance lot +- regrade harus memindahkan qty dan histori cost dengan jelas + +## 12. Traceability Rules +### Backward Trace +sales -> sales_allocations -> inventory_lots -> receipt_lines -> purchase_lines -> purchases -> suppliers + +### Forward Trace +suppliers -> purchases -> inventory_lots -> sales_allocations -> sales_lines -> sales -> customers + +### Process Trace +inventory_lots -> sorting_sessions -> sorting_results -> stock_adjustments -> inventory_movements + +## 13. Key Screens +- Dashboard +- Purchase List +- Purchase Detail/Form +- Receipt Form +- Receipt Detail +- Stock Summary +- Stock Lot List +- Lot Detail +- Sorting Session Form +- Sales Form +- Allocation Screen +- Picking Screen +- Adjustment Form +- Regrade Form +- Barcode Lookup +- Reports + +## 14. KPI / Success Metrics +- akurasi stok per lot +- kecepatan trace dari penjualan ke supplier +- penurunan selisih opname +- akurasi HPP penjualan +- kecepatan receiving dan picking +- visibilitas susut per lot dan supplier + +## 15. MVP Recommendation +### Phase 1 +- master data +- purchasing +- receiving +- inventory lot +- stock summary +- sales +- sales allocation +- movement ledger +- QR label dasar + +### Phase 2 +- sorting +- regrade +- stock adjustment detail +- transfer gudang +- stock opname scan + +### Phase 3 +- analytics supplier quality +- analytics margin dan shrinkage +- mobile scanner workflow lebih maju +- smarter allocation engine + +## 16. Risiko Implementasi +- user ingin proses cepat tetapi model data kompleks +- salah desain costing akan merusak laporan margin +- jika lot discipline tidak dijaga, traceability rusak +- jika QR hanya kosmetik tanpa lot discipline, manfaatnya kecil + +## 17. Keputusan Produk yang Direkomendasikan +- jadikan lot sebagai sumber kebenaran traceability +- pakai stock summary hanya sebagai view agregat +- gunakan FIFO default dengan override manual +- pakai QR code di level lot +- prioritaskan lot detail, allocation, dan movement ledger di fase awal + +## 18. Kesimpulan +Produk ini adalah sistem inventory walet yang berorientasi pada lot, costing akurat, dan traceability penuh. Dengan desain ini, bisnis dapat mengelola pembelian multi jenis-grade, sortasi, penjualan campuran, susut, dan audit histori secara jauh lebih aman dibanding inventory biasa. \ No newline at end of file diff --git a/docs/project-spec/walet-prd.pdf b/docs/project-spec/walet-prd.pdf new file mode 100644 index 0000000..4541927 Binary files /dev/null and b/docs/project-spec/walet-prd.pdf differ diff --git a/docs/project-spec/walet-report-queries.sql b/docs/project-spec/walet-report-queries.sql new file mode 100644 index 0000000..c5db0a3 --- /dev/null +++ b/docs/project-spec/walet-report-queries.sql @@ -0,0 +1,165 @@ +-- 1. Stock summary per jenis-grade-gudang +SELECT + it.name AS item_type, + ig.name AS item_grade, + w.name AS warehouse, + SUM(il.available_qty) AS qty_total, + SUM(il.available_qty * il.unit_cost) AS inventory_value, + COUNT(*) FILTER (WHERE il.status = 'ACTIVE') AS active_lot_count +FROM inventory_lots il +JOIN item_types it ON it.id = il.item_type_id +JOIN item_grades ig ON ig.id = il.item_grade_id +JOIN warehouses w ON w.id = il.warehouse_id +WHERE il.status = 'ACTIVE' +GROUP BY it.name, ig.name, w.name +ORDER BY it.name, ig.name, w.name; + +-- 2. Stock aging report +SELECT + il.lot_code, + s.name AS supplier, + it.name AS item_type, + ig.name AS item_grade, + il.available_qty, + il.unit_cost, + il.received_at, + DATE_PART('day', NOW() - il.received_at) AS aging_days, + il.status +FROM inventory_lots il +LEFT JOIN suppliers s ON s.id = il.supplier_id +JOIN item_types it ON it.id = il.item_type_id +JOIN item_grades ig ON ig.id = il.item_grade_id +WHERE il.available_qty > 0 +ORDER BY aging_days DESC; + +-- 3. Purchase history per supplier +SELECT + p.purchase_no, + p.purchase_date, + s.name AS supplier, + COUNT(pl.id) AS total_lines, + SUM(pl.subtotal) AS grand_total +FROM purchases p +JOIN suppliers s ON s.id = p.supplier_id +JOIN purchase_lines pl ON pl.purchase_id = p.id +GROUP BY p.id, p.purchase_no, p.purchase_date, s.name +ORDER BY p.purchase_date DESC; + +-- 4. Sales margin report +SELECT + sa.sales_no, + sa.sales_date, + c.name AS customer, + SUM(sl.subtotal) AS sales_total, + SUM(sl.costing_total) AS costing_total, + SUM(sl.gross_margin) AS gross_margin +FROM sales sa +JOIN customers c ON c.id = sa.customer_id +JOIN sales_lines sl ON sl.sales_id = sa.id +GROUP BY sa.id, sa.sales_no, sa.sales_date, c.name +ORDER BY sa.sales_date DESC; + +-- 5. Supplier quality report berdasarkan hasil sortasi +SELECT + sup.name AS supplier, + parent.lot_code AS source_lot, + ss.input_qty, + ss.output_qty, + ss.shrinkage_qty, + ROUND((ss.output_qty / NULLIF(ss.input_qty, 0)) * 100, 2) AS yield_percent +FROM sorting_sessions ss +JOIN inventory_lots parent ON parent.id = ss.source_lot_id +LEFT JOIN suppliers sup ON sup.id = parent.supplier_id +ORDER BY ss.sorting_date DESC; + +-- 6. Shrinkage report +SELECT + sa.adjustment_no, + il.lot_code, + sup.name AS supplier, + it.name AS item_type, + ig.name AS item_grade, + ar.name AS reason, + sa.qty_before, + sa.qty_change, + sa.qty_after, + sa.cost_impact, + sa.adjustment_date +FROM stock_adjustments sa +JOIN inventory_lots il ON il.id = sa.inventory_lot_id +LEFT JOIN suppliers sup ON sup.id = il.supplier_id +JOIN item_types it ON it.id = il.item_type_id +JOIN item_grades ig ON ig.id = il.item_grade_id +JOIN adjustment_reasons ar ON ar.id = sa.reason_id +ORDER BY sa.adjustment_date DESC; + +-- 7. Traceability by sales +SELECT + s.sales_no, + c.name AS customer, + sl.id AS sales_line_id, + it.name AS item_type, + ig.name AS item_grade, + sl.qty_sold, + il.lot_code, + sup.name AS supplier, + sa.qty_allocated, + sa.unit_cost, + sa.total_cost, + p.purchase_no +FROM sales s +JOIN customers c ON c.id = s.customer_id +JOIN sales_lines sl ON sl.sales_id = s.id +JOIN item_types it ON it.id = sl.item_type_id +JOIN item_grades ig ON ig.id = sl.item_grade_id +JOIN sales_allocations sa ON sa.sales_line_id = sl.id +JOIN inventory_lots il ON il.id = sa.inventory_lot_id +LEFT JOIN suppliers sup ON sup.id = il.supplier_id +LEFT JOIN purchases p ON p.id = il.purchase_id +ORDER BY s.sales_no, sl.id, il.lot_code; + +-- 8. Forward trace by lot +SELECT + il.lot_code, + sup.name AS supplier, + s.sales_no, + c.name AS customer, + sa.qty_allocated, + sa.total_cost, + s.sales_date +FROM inventory_lots il +JOIN sales_allocations sa ON sa.inventory_lot_id = il.id +JOIN sales_lines sl ON sl.id = sa.sales_line_id +JOIN sales s ON s.id = sl.sales_id +JOIN customers c ON c.id = s.customer_id +LEFT JOIN suppliers sup ON sup.id = il.supplier_id +ORDER BY il.lot_code, s.sales_date; + +-- 9. Movement ledger per lot +SELECT + il.lot_code, + im.movement_no, + im.movement_type, + im.qty_in, + im.qty_out, + im.balance_after, + im.unit_cost, + im.reference_type, + im.reference_id, + im.movement_date, + u.name AS created_by +FROM inventory_movements im +JOIN inventory_lots il ON il.id = im.inventory_lot_id +JOIN users u ON u.id = im.created_by +ORDER BY il.lot_code, im.movement_date; + +-- 10. Top customer by sales value +SELECT + c.name AS customer, + SUM(sl.subtotal) AS total_sales, + SUM(sl.gross_margin) AS total_margin +FROM sales s +JOIN customers c ON c.id = s.customer_id +JOIN sales_lines sl ON sl.sales_id = s.id +GROUP BY c.name +ORDER BY total_sales DESC; diff --git a/docs/project-spec/walet-role-permission-matrix.md b/docs/project-spec/walet-role-permission-matrix.md new file mode 100644 index 0000000..2cbd113 --- /dev/null +++ b/docs/project-spec/walet-role-permission-matrix.md @@ -0,0 +1,159 @@ +# Role Permission Matrix Sistem Inventory Walet + +## 1. Tujuan +Dokumen ini mendefinisikan hak akses per role agar implementasi backend, frontend, dan audit trail konsisten. + +## 2. Role Utama +- Owner +- Admin Purchasing +- Admin Gudang +- Tim Sortasi / QC +- Admin Sales + +## 3. Prinsip Akses +- setiap aksi sensitif harus tercatat user-nya +- role hanya boleh akses modul yang relevan +- owner default lebih banyak read daripada write +- aksi yang memengaruhi qty lot harus dibatasi ketat +- traceability dan report penting minimal bisa dibaca owner + +## 4. Matrix Hak Akses + +| Modul / Aksi | Owner | Purchasing | Gudang | QC | Sales | +|---|---|---|---|---|---| +| Lihat Dashboard | Yes | Yes | Yes | Yes | Yes | +| Lihat Master Data | Yes | Yes | Yes | Yes | Yes | +| Buat/Edit Supplier | Yes | Yes | No | No | No | +| Buat/Edit Customer | Yes | No | No | No | Yes | +| Buat/Edit Jenis & Grade | Yes | Limited | No | Limited | No | +| Buat/Edit Gudang & Lokasi | Yes | No | Yes | No | No | +| Buat Pembelian | Yes | Yes | No | No | No | +| Edit Draft Pembelian | Yes | Yes | No | No | No | +| Submit Pembelian | Yes | Yes | No | No | No | +| Cancel Pembelian | Yes | Yes | No | No | No | +| Lihat Histori Harga Beli | Yes | Yes | No | No | No | +| Buat Receipt | Yes | No | Yes | No | No | +| Finalize Receipt | Yes | No | Yes | No | No | +| Generate Lot | Yes | No | Yes | No | No | +| Print/Reprint Label | Yes | No | Yes | Yes | No | +| Lihat Stock Summary | Yes | Yes | Yes | Yes | Yes | +| Lihat Stock Lot | Yes | No | Yes | Yes | Yes | +| Lihat Lot Detail | Yes | No | Yes | Yes | Yes | +| Hold/Release Lot | Yes | No | Yes | Limited | No | +| Transfer Lot | Yes | No | Yes | No | No | +| Stock Opname | Yes | No | Yes | No | No | +| Stock Adjustment | Yes | No | Yes | Limited | No | +| Buat Sorting Session | Yes | No | No | Yes | No | +| Submit Hasil Sortasi | Yes | No | No | Yes | No | +| Regrade Lot | Yes | No | No | Yes | No | +| Lihat Traceability | Yes | Limited | Yes | Yes | Yes | +| Buat Sales Order | Yes | No | No | No | Yes | +| Edit Draft Sales | Yes | No | No | No | Yes | +| Allocate Lot ke Sales | Yes | No | No | No | Yes | +| Auto Allocate | Yes | No | No | No | Yes | +| Confirm Picking | Yes | No | Yes | No | Yes | +| Finalize Sales | Yes | No | No | No | Yes | +| Buat Sales Return | Yes | No | Yes | No | Yes | +| Buat Purchase Return | Yes | Yes | Yes | No | No | +| Lihat Reports | Yes | Limited | Limited | Limited | Limited | +| Export Reports | Yes | Limited | Limited | Limited | Limited | +| Ubah Settings Sistem | Yes | No | No | No | No | +| Kelola User & Role | Yes | No | No | No | No | + +## 5. Keterangan Limited +Limited berarti akses dibatasi konteks tertentu. + +### Owner +- full read +- write untuk approval, oversight, dan kondisi pengecualian + +### Purchasing limited pada jenis/grade +- boleh usulkan / manage jika memang proses bisnis mengizinkan +- idealnya tetap lewat owner/admin utama + +### QC limited pada hold/release atau adjustment +- QC bisa memberi rekomendasi +- aksi final bisa dibatasi butuh approval gudang/owner jika diinginkan + +### Reports limited +Role non-owner hanya melihat laporan relevan: +- Purchasing: pembelian, supplier quality sederhana +- Gudang: stok, aging, shrinkage operasional +- QC: sortasi, regrade, shrinkage +- Sales: sales, margin dasar, trace per order + +## 6. Rekomendasi Rule Akses Tambahan +### Rule 1 +Lot CLOSED atau REJECTED tidak bisa dipakai untuk sales allocation. + +### Rule 2 +Lot HOLD tidak bisa dipicking sampai dilepas. + +### Rule 3 +Adjustment dengan dampak besar bisa diberi approval tambahan. + +### Rule 4 +Reprint label harus tercatat siapa yang melakukan. + +### Rule 5 +Finalize receipt, finalize sales, stock adjustment, regrade, dan sorting submit harus masuk audit log wajib. + +## 7. Audit Log Minimum +Aksi yang wajib tercatat: +- create/update/cancel purchase +- finalize receipt +- generate lot +- transfer lot +- hold/release lot +- stock adjustment +- sorting submit +- regrade +- allocation submit +- picking confirm +- finalize sales +- returns +- label reprint + +## 8. Frontend Visibility Rules +### Owner +Lihat semua menu. + +### Purchasing +Tampilkan: +- dashboard +- supplier +- purchases +- purchase returns +- laporan pembelian + +### Gudang +Tampilkan: +- dashboard +- receipts +- lots +- transfer +- stock adjustments +- barcode +- stock reports + +### QC +Tampilkan: +- dashboard +- lots +- sorting +- regrade +- QC-related reports + +### Sales +Tampilkan: +- dashboard +- customers +- sales +- allocation +- picking +- sales returns +- traceability lookup +- sales reports + +## 9. Kesimpulan +Matrix ini menjaga agar sistem tetap aman, jelas, dan sesuai alur kerja nyata. Role paling sensitif terhadap integritas stok adalah Gudang, QC, dan Sales, sehingga semua aksi mereka harus terhubung ke audit trail yang kuat. \ No newline at end of file diff --git a/docs/project-spec/walet-sample-transactions.html b/docs/project-spec/walet-sample-transactions.html new file mode 100644 index 0000000..cdb621d --- /dev/null +++ b/docs/project-spec/walet-sample-transactions.html @@ -0,0 +1,76 @@ +Contoh Transaksi End-to-End Walet
# Contoh Transaksi End-to-End Sistem Inventory Walet
+
+## Skenario 1. Pembelian Multi Jenis Multi Grade
+Supplier A mengirim:
+- Jenis A Grade A = 50 kg
+- Jenis A Grade B = 20 kg
+- Jenis B Grade A = 15 kg
+
+Purchase dibuat dengan 3 line.
+Saat receiving, masing-masing line menjadi lot:
+- LOT-260428-SPA-001 = Jenis A Grade A 50 kg
+- LOT-260428-SPA-002 = Jenis A Grade B 20 kg
+- LOT-260428-SPA-003 = Jenis B Grade A 15 kg
+
+## Skenario 2. Pembelian Butuh Sortasi
+Supplier B mengirim barang campur 40 kg.
+Masuk sebagai:
+- LOT-260428-SPB-001 = provisional
+
+Setelah sortasi:
+- LOT-260428-SPB-001-S1 = Jenis A Grade A 18 kg
+- LOT-260428-SPB-001-S2 = Jenis A Grade B 12 kg
+- LOT-260428-SPB-001-S3 = Jenis B Grade A 7 kg
+- Susut/reject = 3 kg
+
+## Skenario 3. Penjualan Campuran dari Banyak Lot
+Customer X membeli:
+- Jenis A Grade A = 30 kg
+
+Sistem melihat stok:
+- LOT-260428-SPA-001 tersedia 50 kg
+- LOT-260428-SPB-001-S1 tersedia 18 kg
+
+Allocation:
+- 20 kg dari LOT-260428-SPA-001
+- 10 kg dari LOT-260428-SPB-001-S1
+
+Jika cost:
+- LOT-260428-SPA-001 = 18.000.000/kg
+- LOT-260428-SPB-001-S1 = 19.000.000/kg
+
+HPP line:
+- 20 x 18.000.000 = 360.000.000
+- 10 x 19.000.000 = 190.000.000
+- Total cost = 550.000.000
+
+## Skenario 4. Regrade
+Dari LOT-260428-SPA-001, setelah inspeksi ulang:
+- 5 kg turun dari Grade A menjadi Grade B
+
+Maka:
+- lot Grade A dikurangi 5 kg
+- dibuat lot baru Grade B atau ditambahkan ke lot grade B aktif
+- ledger mencatat REGRADE_OUT dan REGRADE_IN
+
+## Skenario 5. Shrinkage
+Saat stock opname, ditemukan selisih -1.2 kg pada LOT-260428-SPB-001-S2.
+
+Sistem mencatat:
+- stock adjustment
+- reason: SHRINKAGE
+- qty_before
+- qty_after
+- cost impact
+
+## Skenario 6. Traceability
+Untuk invoice SLS-001, sistem dapat menampilkan:
+- 20 kg berasal dari LOT-260428-SPA-001, Supplier A
+- 10 kg berasal dari LOT-260428-SPB-001-S1, Supplier B
+
+Untuk LOT-260428-SPA-001, sistem dapat menampilkan:
+- asal purchase: PO-001
+- supplier: Supplier A
+- dipakai di invoice: SLS-001, SLS-004, SLS-006
+- pernah diregrade: ya/tidak
+- pernah adjustment: ya/tidak
\ No newline at end of file diff --git a/docs/project-spec/walet-sample-transactions.md b/docs/project-spec/walet-sample-transactions.md new file mode 100644 index 0000000..a6b6c7c --- /dev/null +++ b/docs/project-spec/walet-sample-transactions.md @@ -0,0 +1,76 @@ +# Contoh Transaksi End-to-End Sistem Inventory Walet + +## Skenario 1. Pembelian Multi Jenis Multi Grade +Supplier A mengirim: +- Jenis A Grade A = 50 kg +- Jenis A Grade B = 20 kg +- Jenis B Grade A = 15 kg + +Purchase dibuat dengan 3 line. +Saat receiving, masing-masing line menjadi lot: +- LOT-260428-SPA-001 = Jenis A Grade A 50 kg +- LOT-260428-SPA-002 = Jenis A Grade B 20 kg +- LOT-260428-SPA-003 = Jenis B Grade A 15 kg + +## Skenario 2. Pembelian Butuh Sortasi +Supplier B mengirim barang campur 40 kg. +Masuk sebagai: +- LOT-260428-SPB-001 = provisional + +Setelah sortasi: +- LOT-260428-SPB-001-S1 = Jenis A Grade A 18 kg +- LOT-260428-SPB-001-S2 = Jenis A Grade B 12 kg +- LOT-260428-SPB-001-S3 = Jenis B Grade A 7 kg +- Susut/reject = 3 kg + +## Skenario 3. Penjualan Campuran dari Banyak Lot +Customer X membeli: +- Jenis A Grade A = 30 kg + +Sistem melihat stok: +- LOT-260428-SPA-001 tersedia 50 kg +- LOT-260428-SPB-001-S1 tersedia 18 kg + +Allocation: +- 20 kg dari LOT-260428-SPA-001 +- 10 kg dari LOT-260428-SPB-001-S1 + +Jika cost: +- LOT-260428-SPA-001 = 18.000.000/kg +- LOT-260428-SPB-001-S1 = 19.000.000/kg + +HPP line: +- 20 x 18.000.000 = 360.000.000 +- 10 x 19.000.000 = 190.000.000 +- Total cost = 550.000.000 + +## Skenario 4. Regrade +Dari LOT-260428-SPA-001, setelah inspeksi ulang: +- 5 kg turun dari Grade A menjadi Grade B + +Maka: +- lot Grade A dikurangi 5 kg +- dibuat lot baru Grade B atau ditambahkan ke lot grade B aktif +- ledger mencatat REGRADE_OUT dan REGRADE_IN + +## Skenario 5. Shrinkage +Saat stock opname, ditemukan selisih -1.2 kg pada LOT-260428-SPB-001-S2. + +Sistem mencatat: +- stock adjustment +- reason: SHRINKAGE +- qty_before +- qty_after +- cost impact + +## Skenario 6. Traceability +Untuk invoice SLS-001, sistem dapat menampilkan: +- 20 kg berasal dari LOT-260428-SPA-001, Supplier A +- 10 kg berasal dari LOT-260428-SPB-001-S1, Supplier B + +Untuk LOT-260428-SPA-001, sistem dapat menampilkan: +- asal purchase: PO-001 +- supplier: Supplier A +- dipakai di invoice: SLS-001, SLS-004, SLS-006 +- pernah diregrade: ya/tidak +- pernah adjustment: ya/tidak \ No newline at end of file diff --git a/docs/project-spec/walet-sample-transactions.pdf b/docs/project-spec/walet-sample-transactions.pdf new file mode 100644 index 0000000..9f1ddb6 Binary files /dev/null and b/docs/project-spec/walet-sample-transactions.pdf differ diff --git a/docs/project-spec/walet-schema.html b/docs/project-spec/walet-schema.html new file mode 100644 index 0000000..f59a11b --- /dev/null +++ b/docs/project-spec/walet-schema.html @@ -0,0 +1,366 @@ +

SQL Schema Sistem Inventory Walet

CREATE TABLE roles (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(50) NOT NULL UNIQUE,
+  name VARCHAR(100) NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE users (
+  id BIGSERIAL PRIMARY KEY,
+  role_id BIGINT NOT NULL REFERENCES roles(id),
+  name VARCHAR(150) NOT NULL,
+  email VARCHAR(150),
+  phone VARCHAR(50),
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE suppliers (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(50) NOT NULL UNIQUE,
+  name VARCHAR(150) NOT NULL,
+  phone VARCHAR(50),
+  email VARCHAR(150),
+  bank_name VARCHAR(100),
+  bank_account_number VARCHAR(100),
+  address TEXT,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE customers (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(50) NOT NULL UNIQUE,
+  name VARCHAR(150) NOT NULL,
+  phone VARCHAR(50),
+  email VARCHAR(150),
+  bank_name VARCHAR(100),
+  bank_account_number VARCHAR(100),
+  address TEXT,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE units (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(20) NOT NULL UNIQUE,
+  name VARCHAR(50) NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE item_types (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(50) NOT NULL UNIQUE,
+  name VARCHAR(100) NOT NULL,
+  description TEXT,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE item_grades (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(50) NOT NULL UNIQUE,
+  name VARCHAR(100) NOT NULL,
+  rank_order INT,
+  description TEXT,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE warehouses (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(50) NOT NULL UNIQUE,
+  name VARCHAR(100) NOT NULL,
+  address TEXT,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE warehouse_locations (
+  id BIGSERIAL PRIMARY KEY,
+  warehouse_id BIGINT NOT NULL REFERENCES warehouses(id),
+  code VARCHAR(50) NOT NULL,
+  name VARCHAR(100) NOT NULL,
+  location_type VARCHAR(50),
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  UNIQUE (warehouse_id, code)
+);
+
+CREATE TABLE adjustment_reasons (
+  id BIGSERIAL PRIMARY KEY,
+  code VARCHAR(50) NOT NULL UNIQUE,
+  name VARCHAR(100) NOT NULL,
+  category VARCHAR(50) NOT NULL,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE purchases (
+  id BIGSERIAL PRIMARY KEY,
+  purchase_no VARCHAR(50) NOT NULL UNIQUE,
+  supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
+  purchase_date DATE NOT NULL,
+  supplier_invoice_no VARCHAR(100),
+  status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+  notes TEXT,
+  created_by BIGINT NOT NULL REFERENCES users(id),
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE purchase_lines (
+  id BIGSERIAL PRIMARY KEY,
+  purchase_id BIGINT NOT NULL REFERENCES purchases(id) ON DELETE CASCADE,
+  item_type_id BIGINT NOT NULL REFERENCES item_types(id),
+  item_grade_id BIGINT REFERENCES item_grades(id),
+  qty_ordered NUMERIC(18,3) NOT NULL,
+  unit_id BIGINT NOT NULL REFERENCES units(id),
+  unit_price NUMERIC(18,2) NOT NULL,
+  subtotal NUMERIC(18,2) NOT NULL,
+  classification_status VARCHAR(20) NOT NULL DEFAULT 'FINAL',
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE receipts (
+  id BIGSERIAL PRIMARY KEY,
+  receipt_no VARCHAR(50) NOT NULL UNIQUE,
+  purchase_id BIGINT NOT NULL REFERENCES purchases(id),
+  supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
+  receipt_date DATE NOT NULL,
+  status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+  notes TEXT,
+  received_by BIGINT NOT NULL REFERENCES users(id),
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE receipt_lines (
+  id BIGSERIAL PRIMARY KEY,
+  receipt_id BIGINT NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
+  purchase_line_id BIGINT NOT NULL REFERENCES purchase_lines(id),
+  item_type_id BIGINT NOT NULL REFERENCES item_types(id),
+  item_grade_id BIGINT REFERENCES item_grades(id),
+  qty_received NUMERIC(18,3) NOT NULL,
+  qty_accepted NUMERIC(18,3) NOT NULL,
+  qty_rejected NUMERIC(18,3) NOT NULL DEFAULT 0,
+  unit_id BIGINT NOT NULL REFERENCES units(id),
+  unit_cost NUMERIC(18,2) NOT NULL,
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE inventory_lots (
+  id BIGSERIAL PRIMARY KEY,
+  lot_code VARCHAR(100) NOT NULL UNIQUE,
+  parent_lot_id BIGINT REFERENCES inventory_lots(id),
+  source_type VARCHAR(30) NOT NULL,
+  source_ref_id BIGINT,
+  supplier_id BIGINT REFERENCES suppliers(id),
+  purchase_id BIGINT REFERENCES purchases(id),
+  purchase_line_id BIGINT REFERENCES purchase_lines(id),
+  receipt_id BIGINT REFERENCES receipts(id),
+  receipt_line_id BIGINT REFERENCES receipt_lines(id),
+  item_type_id BIGINT NOT NULL REFERENCES item_types(id),
+  item_grade_id BIGINT NOT NULL REFERENCES item_grades(id),
+  warehouse_id BIGINT NOT NULL REFERENCES warehouses(id),
+  warehouse_location_id BIGINT REFERENCES warehouse_locations(id),
+  original_qty NUMERIC(18,3) NOT NULL,
+  available_qty NUMERIC(18,3) NOT NULL,
+  reserved_qty NUMERIC(18,3) NOT NULL DEFAULT 0,
+  damaged_qty NUMERIC(18,3) NOT NULL DEFAULT 0,
+  shrinkage_qty NUMERIC(18,3) NOT NULL DEFAULT 0,
+  unit_id BIGINT NOT NULL REFERENCES units(id),
+  unit_cost NUMERIC(18,2) NOT NULL,
+  received_at TIMESTAMP NOT NULL,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  qr_code_value VARCHAR(255),
+  barcode_value VARCHAR(255),
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_inventory_lots_item_grade_wh ON inventory_lots(item_type_id, item_grade_id, warehouse_id);
+CREATE INDEX idx_inventory_lots_supplier_item_grade ON inventory_lots(supplier_id, item_type_id, item_grade_id);
+
+CREATE TABLE sorting_sessions (
+  id BIGSERIAL PRIMARY KEY,
+  sorting_no VARCHAR(50) NOT NULL UNIQUE,
+  source_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id),
+  sorting_date TIMESTAMP NOT NULL,
+  input_qty NUMERIC(18,3) NOT NULL,
+  output_qty NUMERIC(18,3) NOT NULL,
+  shrinkage_qty NUMERIC(18,3) NOT NULL DEFAULT 0,
+  notes TEXT,
+  sorted_by BIGINT NOT NULL REFERENCES users(id),
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sorting_results (
+  id BIGSERIAL PRIMARY KEY,
+  sorting_session_id BIGINT NOT NULL REFERENCES sorting_sessions(id) ON DELETE CASCADE,
+  result_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id),
+  item_type_id BIGINT NOT NULL REFERENCES item_types(id),
+  item_grade_id BIGINT NOT NULL REFERENCES item_grades(id),
+  qty_result NUMERIC(18,3) NOT NULL,
+  unit_cost NUMERIC(18,2) NOT NULL,
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sales (
+  id BIGSERIAL PRIMARY KEY,
+  sales_no VARCHAR(50) NOT NULL UNIQUE,
+  customer_id BIGINT NOT NULL REFERENCES customers(id),
+  sales_date DATE NOT NULL,
+  status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+  notes TEXT,
+  created_by BIGINT NOT NULL REFERENCES users(id),
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sales_lines (
+  id BIGSERIAL PRIMARY KEY,
+  sales_id BIGINT NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
+  item_type_id BIGINT NOT NULL REFERENCES item_types(id),
+  item_grade_id BIGINT NOT NULL REFERENCES item_grades(id),
+  qty_sold NUMERIC(18,3) NOT NULL,
+  unit_id BIGINT NOT NULL REFERENCES units(id),
+  selling_price NUMERIC(18,2) NOT NULL,
+  subtotal NUMERIC(18,2) NOT NULL,
+  costing_total NUMERIC(18,2) NOT NULL DEFAULT 0,
+  gross_margin NUMERIC(18,2) NOT NULL DEFAULT 0,
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sales_allocations (
+  id BIGSERIAL PRIMARY KEY,
+  sales_line_id BIGINT NOT NULL REFERENCES sales_lines(id) ON DELETE CASCADE,
+  inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id),
+  qty_allocated NUMERIC(18,3) NOT NULL,
+  unit_cost NUMERIC(18,2) NOT NULL,
+  total_cost NUMERIC(18,2) NOT NULL,
+  allocated_at TIMESTAMP NOT NULL,
+  allocated_by BIGINT NOT NULL REFERENCES users(id),
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE inventory_movements (
+  id BIGSERIAL PRIMARY KEY,
+  movement_no VARCHAR(50) NOT NULL UNIQUE,
+  movement_type VARCHAR(30) NOT NULL,
+  inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id),
+  related_lot_id BIGINT REFERENCES inventory_lots(id),
+  warehouse_id BIGINT NOT NULL REFERENCES warehouses(id),
+  warehouse_location_id BIGINT REFERENCES warehouse_locations(id),
+  qty_in NUMERIC(18,3) NOT NULL DEFAULT 0,
+  qty_out NUMERIC(18,3) NOT NULL DEFAULT 0,
+  balance_after NUMERIC(18,3) NOT NULL,
+  unit_cost NUMERIC(18,2) NOT NULL,
+  reference_type VARCHAR(30) NOT NULL,
+  reference_id BIGINT NOT NULL,
+  reason_id BIGINT REFERENCES adjustment_reasons(id),
+  movement_date TIMESTAMP NOT NULL,
+  created_by BIGINT NOT NULL REFERENCES users(id),
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sales_returns (
+  id BIGSERIAL PRIMARY KEY,
+  sales_id BIGINT NOT NULL REFERENCES sales(id),
+  customer_id BIGINT NOT NULL REFERENCES customers(id),
+  return_date DATE NOT NULL,
+  status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE sales_return_lines (
+  id BIGSERIAL PRIMARY KEY,
+  sales_return_id BIGINT NOT NULL REFERENCES sales_returns(id) ON DELETE CASCADE,
+  sales_line_id BIGINT NOT NULL REFERENCES sales_lines(id),
+  inventory_lot_id BIGINT REFERENCES inventory_lots(id),
+  item_type_id BIGINT NOT NULL REFERENCES item_types(id),
+  item_grade_id BIGINT NOT NULL REFERENCES item_grades(id),
+  qty_returned NUMERIC(18,3) NOT NULL,
+  return_condition VARCHAR(50),
+  resolution VARCHAR(50),
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE purchase_returns (
+  id BIGSERIAL PRIMARY KEY,
+  purchase_id BIGINT NOT NULL REFERENCES purchases(id),
+  supplier_id BIGINT NOT NULL REFERENCES suppliers(id),
+  return_date DATE NOT NULL,
+  status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE purchase_return_lines (
+  id BIGSERIAL PRIMARY KEY,
+  purchase_return_id BIGINT NOT NULL REFERENCES purchase_returns(id) ON DELETE CASCADE,
+  inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id),
+  qty_returned NUMERIC(18,3) NOT NULL,
+  unit_cost NUMERIC(18,2) NOT NULL,
+  notes TEXT,
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE stock_adjustments (
+  id BIGSERIAL PRIMARY KEY,
+  adjustment_no VARCHAR(50) NOT NULL UNIQUE,
+  inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id),
+  adjustment_type VARCHAR(30) NOT NULL,
+  reason_id BIGINT NOT NULL REFERENCES adjustment_reasons(id),
+  qty_before NUMERIC(18,3) NOT NULL,
+  qty_change NUMERIC(18,3) NOT NULL,
+  qty_after NUMERIC(18,3) NOT NULL,
+  cost_impact NUMERIC(18,2) NOT NULL DEFAULT 0,
+  adjustment_date TIMESTAMP NOT NULL,
+  notes TEXT,
+  created_by BIGINT NOT NULL REFERENCES users(id),
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE lot_labels (
+  id BIGSERIAL PRIMARY KEY,
+  inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id) ON DELETE CASCADE,
+  label_type VARCHAR(20) NOT NULL,
+  label_value VARCHAR(255) NOT NULL,
+  printed_at TIMESTAMP,
+  printed_by BIGINT REFERENCES users(id),
+  print_count INT NOT NULL DEFAULT 0,
+  status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
+  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
\ No newline at end of file diff --git a/docs/project-spec/walet-schema.pdf b/docs/project-spec/walet-schema.pdf new file mode 100644 index 0000000..dcbb3fc Binary files /dev/null and b/docs/project-spec/walet-schema.pdf differ diff --git a/docs/project-spec/walet-schema.sql b/docs/project-spec/walet-schema.sql new file mode 100644 index 0000000..8861d19 --- /dev/null +++ b/docs/project-spec/walet-schema.sql @@ -0,0 +1,366 @@ +CREATE TABLE roles ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL REFERENCES roles(id), + name VARCHAR(150) NOT NULL, + email VARCHAR(150), + phone VARCHAR(50), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE suppliers ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(150) NOT NULL, + phone VARCHAR(50), + email VARCHAR(150), + bank_name VARCHAR(100), + bank_account_number VARCHAR(100), + address TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE customers ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(150) NOT NULL, + phone VARCHAR(50), + email VARCHAR(150), + bank_name VARCHAR(100), + bank_account_number VARCHAR(100), + address TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE units ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE item_types ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE item_grades ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + rank_order INT, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE warehouses ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + address TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE warehouse_locations ( + id BIGSERIAL PRIMARY KEY, + warehouse_id BIGINT NOT NULL REFERENCES warehouses(id), + code VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + location_type VARCHAR(50), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (warehouse_id, code) +); + +CREATE TABLE adjustment_reasons ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + category VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE purchases ( + id BIGSERIAL PRIMARY KEY, + purchase_no VARCHAR(50) NOT NULL UNIQUE, + supplier_id BIGINT NOT NULL REFERENCES suppliers(id), + purchase_date DATE NOT NULL, + supplier_invoice_no VARCHAR(100), + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + notes TEXT, + created_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE purchase_lines ( + id BIGSERIAL PRIMARY KEY, + purchase_id BIGINT NOT NULL REFERENCES purchases(id) ON DELETE CASCADE, + item_type_id BIGINT NOT NULL REFERENCES item_types(id), + item_grade_id BIGINT REFERENCES item_grades(id), + qty_ordered NUMERIC(18,3) NOT NULL, + unit_id BIGINT NOT NULL REFERENCES units(id), + unit_price NUMERIC(18,2) NOT NULL, + subtotal NUMERIC(18,2) NOT NULL, + classification_status VARCHAR(20) NOT NULL DEFAULT 'FINAL', + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE receipts ( + id BIGSERIAL PRIMARY KEY, + receipt_no VARCHAR(50) NOT NULL UNIQUE, + purchase_id BIGINT NOT NULL REFERENCES purchases(id), + supplier_id BIGINT NOT NULL REFERENCES suppliers(id), + receipt_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + notes TEXT, + received_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE receipt_lines ( + id BIGSERIAL PRIMARY KEY, + receipt_id BIGINT NOT NULL REFERENCES receipts(id) ON DELETE CASCADE, + purchase_line_id BIGINT NOT NULL REFERENCES purchase_lines(id), + item_type_id BIGINT NOT NULL REFERENCES item_types(id), + item_grade_id BIGINT REFERENCES item_grades(id), + qty_received NUMERIC(18,3) NOT NULL, + qty_accepted NUMERIC(18,3) NOT NULL, + qty_rejected NUMERIC(18,3) NOT NULL DEFAULT 0, + unit_id BIGINT NOT NULL REFERENCES units(id), + unit_cost NUMERIC(18,2) NOT NULL, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE inventory_lots ( + id BIGSERIAL PRIMARY KEY, + lot_code VARCHAR(100) NOT NULL UNIQUE, + parent_lot_id BIGINT REFERENCES inventory_lots(id), + source_type VARCHAR(30) NOT NULL, + source_ref_id BIGINT, + supplier_id BIGINT REFERENCES suppliers(id), + purchase_id BIGINT REFERENCES purchases(id), + purchase_line_id BIGINT REFERENCES purchase_lines(id), + receipt_id BIGINT REFERENCES receipts(id), + receipt_line_id BIGINT REFERENCES receipt_lines(id), + item_type_id BIGINT NOT NULL REFERENCES item_types(id), + item_grade_id BIGINT NOT NULL REFERENCES item_grades(id), + warehouse_id BIGINT NOT NULL REFERENCES warehouses(id), + warehouse_location_id BIGINT REFERENCES warehouse_locations(id), + original_qty NUMERIC(18,3) NOT NULL, + available_qty NUMERIC(18,3) NOT NULL, + reserved_qty NUMERIC(18,3) NOT NULL DEFAULT 0, + damaged_qty NUMERIC(18,3) NOT NULL DEFAULT 0, + shrinkage_qty NUMERIC(18,3) NOT NULL DEFAULT 0, + unit_id BIGINT NOT NULL REFERENCES units(id), + unit_cost NUMERIC(18,2) NOT NULL, + received_at TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + qr_code_value VARCHAR(255), + barcode_value VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_inventory_lots_item_grade_wh ON inventory_lots(item_type_id, item_grade_id, warehouse_id); +CREATE INDEX idx_inventory_lots_supplier_item_grade ON inventory_lots(supplier_id, item_type_id, item_grade_id); + +CREATE TABLE sorting_sessions ( + id BIGSERIAL PRIMARY KEY, + sorting_no VARCHAR(50) NOT NULL UNIQUE, + source_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id), + sorting_date TIMESTAMP NOT NULL, + input_qty NUMERIC(18,3) NOT NULL, + output_qty NUMERIC(18,3) NOT NULL, + shrinkage_qty NUMERIC(18,3) NOT NULL DEFAULT 0, + notes TEXT, + sorted_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE sorting_results ( + id BIGSERIAL PRIMARY KEY, + sorting_session_id BIGINT NOT NULL REFERENCES sorting_sessions(id) ON DELETE CASCADE, + result_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id), + item_type_id BIGINT NOT NULL REFERENCES item_types(id), + item_grade_id BIGINT NOT NULL REFERENCES item_grades(id), + qty_result NUMERIC(18,3) NOT NULL, + unit_cost NUMERIC(18,2) NOT NULL, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE sales ( + id BIGSERIAL PRIMARY KEY, + sales_no VARCHAR(50) NOT NULL UNIQUE, + customer_id BIGINT NOT NULL REFERENCES customers(id), + sales_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + notes TEXT, + created_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE sales_lines ( + id BIGSERIAL PRIMARY KEY, + sales_id BIGINT NOT NULL REFERENCES sales(id) ON DELETE CASCADE, + item_type_id BIGINT NOT NULL REFERENCES item_types(id), + item_grade_id BIGINT NOT NULL REFERENCES item_grades(id), + qty_sold NUMERIC(18,3) NOT NULL, + unit_id BIGINT NOT NULL REFERENCES units(id), + selling_price NUMERIC(18,2) NOT NULL, + subtotal NUMERIC(18,2) NOT NULL, + costing_total NUMERIC(18,2) NOT NULL DEFAULT 0, + gross_margin NUMERIC(18,2) NOT NULL DEFAULT 0, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE sales_allocations ( + id BIGSERIAL PRIMARY KEY, + sales_line_id BIGINT NOT NULL REFERENCES sales_lines(id) ON DELETE CASCADE, + inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id), + qty_allocated NUMERIC(18,3) NOT NULL, + unit_cost NUMERIC(18,2) NOT NULL, + total_cost NUMERIC(18,2) NOT NULL, + allocated_at TIMESTAMP NOT NULL, + allocated_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE inventory_movements ( + id BIGSERIAL PRIMARY KEY, + movement_no VARCHAR(50) NOT NULL UNIQUE, + movement_type VARCHAR(30) NOT NULL, + inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id), + related_lot_id BIGINT REFERENCES inventory_lots(id), + warehouse_id BIGINT NOT NULL REFERENCES warehouses(id), + warehouse_location_id BIGINT REFERENCES warehouse_locations(id), + qty_in NUMERIC(18,3) NOT NULL DEFAULT 0, + qty_out NUMERIC(18,3) NOT NULL DEFAULT 0, + balance_after NUMERIC(18,3) NOT NULL, + unit_cost NUMERIC(18,2) NOT NULL, + reference_type VARCHAR(30) NOT NULL, + reference_id BIGINT NOT NULL, + reason_id BIGINT REFERENCES adjustment_reasons(id), + movement_date TIMESTAMP NOT NULL, + created_by BIGINT NOT NULL REFERENCES users(id), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE sales_returns ( + id BIGSERIAL PRIMARY KEY, + sales_id BIGINT NOT NULL REFERENCES sales(id), + customer_id BIGINT NOT NULL REFERENCES customers(id), + return_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE sales_return_lines ( + id BIGSERIAL PRIMARY KEY, + sales_return_id BIGINT NOT NULL REFERENCES sales_returns(id) ON DELETE CASCADE, + sales_line_id BIGINT NOT NULL REFERENCES sales_lines(id), + inventory_lot_id BIGINT REFERENCES inventory_lots(id), + item_type_id BIGINT NOT NULL REFERENCES item_types(id), + item_grade_id BIGINT NOT NULL REFERENCES item_grades(id), + qty_returned NUMERIC(18,3) NOT NULL, + return_condition VARCHAR(50), + resolution VARCHAR(50), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE purchase_returns ( + id BIGSERIAL PRIMARY KEY, + purchase_id BIGINT NOT NULL REFERENCES purchases(id), + supplier_id BIGINT NOT NULL REFERENCES suppliers(id), + return_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE purchase_return_lines ( + id BIGSERIAL PRIMARY KEY, + purchase_return_id BIGINT NOT NULL REFERENCES purchase_returns(id) ON DELETE CASCADE, + inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id), + qty_returned NUMERIC(18,3) NOT NULL, + unit_cost NUMERIC(18,2) NOT NULL, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE stock_adjustments ( + id BIGSERIAL PRIMARY KEY, + adjustment_no VARCHAR(50) NOT NULL UNIQUE, + inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id), + adjustment_type VARCHAR(30) NOT NULL, + reason_id BIGINT NOT NULL REFERENCES adjustment_reasons(id), + qty_before NUMERIC(18,3) NOT NULL, + qty_change NUMERIC(18,3) NOT NULL, + qty_after NUMERIC(18,3) NOT NULL, + cost_impact NUMERIC(18,2) NOT NULL DEFAULT 0, + adjustment_date TIMESTAMP NOT NULL, + notes TEXT, + created_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE lot_labels ( + id BIGSERIAL PRIMARY KEY, + inventory_lot_id BIGINT NOT NULL REFERENCES inventory_lots(id) ON DELETE CASCADE, + label_type VARCHAR(20) NOT NULL, + label_value VARCHAR(255) NOT NULL, + printed_at TIMESTAMP, + printed_by BIGINT REFERENCES users(id), + print_count INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/docs/project-spec/walet-screen-ui-spec.md b/docs/project-spec/walet-screen-ui-spec.md new file mode 100644 index 0000000..f110371 --- /dev/null +++ b/docs/project-spec/walet-screen-ui-spec.md @@ -0,0 +1,532 @@ +# Screen-by-Screen UI Spec +## Sistem Inventory Sarang Burung Walet + +## 1. Login Screen +### Tujuan +Autentikasi user berdasarkan role. + +### Section +- logo / app name +- email/phone field +- password field +- login button +- error message area + +### Notes +- simple, fast, no clutter + +--- + +## 2. Dashboard +### Tujuan +Memberi gambaran cepat kondisi bisnis. + +### Layout +- top header +- metrics cards row +- charts row +- alerts row +- quick actions panel + +### Data utama +- total stok aktif +- nilai inventory +- pembelian bulan ini +- penjualan bulan ini +- shrinkage bulan ini +- lot aging alert + +### CTA +- buat pembelian +- receipt baru +- sales baru +- scan lot + +--- + +## 3. Supplier List +### Tujuan +Kelola data supplier. + +### Layout +- page header +- filter/search bar +- create supplier button +- table list + +### Kolom table +- code +- name +- phone +- email +- bank_name +- bank_account_number +- status +- actions + +### Detail view supplier +Section: +- informasi umum +- informasi pembayaran +- histori pembelian + +--- + +## 4. Customer List +### Tujuan +Kelola data customer. + +### Layout +- page header +- filter/search bar +- create customer button +- table list + +### Kolom table +- code +- name +- phone +- email +- bank_name +- bank_account_number +- status +- actions + +### Detail view customer +Section: +- informasi umum +- informasi pembayaran +- histori penjualan + +--- + +## 5. Purchase List +### Tujuan +Lihat seluruh purchase. + +### Layout +- filter bar +- summary chips +- purchase table + +### Kolom +- purchase no +- supplier +- purchase date +- total +- status +- line count +- actions + +--- + +## 6. Purchase Form +### Tujuan +Membuat purchase multi-line. + +### Header section +- supplier select +- bank info preview supplier +- purchase date +- invoice supplier +- notes + +### Lines section +Kolom: +- item type +- grade +- qty +- unit +- unit price +- subtotal +- classification status +- note + +### Summary section +- total lines +- grand total + +### Actions +- save draft +- submit +- cancel + +--- + +## 7. Receipt List +### Tujuan +Melihat seluruh receipt. + +### Kolom +- receipt no +- purchase no +- supplier +- receipt date +- status +- total lines +- actions + +--- + +## 8. Receipt Form +### Tujuan +Menerima barang dan membentuk lot. + +### Header section +- purchase ref +- supplier +- bank info supplier (readonly) +- receipt date +- receiver + +### Lines section +- item +- ordered qty +- received qty +- accepted qty +- rejected qty +- unit cost +- warehouse +- location + +### Actions +- save receipt +- finalize receipt +- generate lot + +--- + +## 9. Stock Summary +### Tujuan +Ringkasan stok per jenis-grade-gudang. + +### Table columns +- item type +- grade +- warehouse +- qty total +- inventory value +- active lot count + +--- + +## 10. Stock Lot List +### Tujuan +Melihat semua lot aktif/detail. + +### Filter +- supplier +- item type +- grade +- warehouse +- status +- aging + +### Table columns +- lot code +- supplier +- item type +- grade +- available qty +- unit cost +- warehouse +- location +- aging +- status +- actions + +--- + +## 11. Lot Detail +### Tujuan +Pusat traceability dan aksi lot. + +### Section 1. Summary card +- lot code +- supplier +- item type +- grade +- available qty +- unit cost +- status + +### Section 2. Supplier info +- supplier name +- bank_name +- bank_account_number + +### Section 3. Quantity breakdown +- original qty +- available qty +- reserved qty +- damaged qty +- shrinkage qty + +### Section 4. Traceability +- parent lot +- child lots +- purchase ref +- receipt ref + +### Section 5. Movement history +- timeline/table of movements + +### Section 6. Sales usage +- invoice list +- allocated qty +- customer + +### Actions +- hold +- release +- transfer +- regrade +- print label + +--- + +## 12. Sorting Session Form +### Tujuan +Pecah satu lot menjadi beberapa hasil. + +### Section +- source lot selector +- source lot summary +- results table +- shrinkage input +- reject input +- notes + +### Result columns +- item type +- grade +- qty result +- cost + +--- + +## 13. Regrade Form +### Tujuan +Ubah sebagian qty lot ke grade baru. + +### Fields +- source lot +- source grade +- target grade +- qty +- reason +- notes + +### Preview +- qty remaining source +- target lot result + +--- + +## 14. Sales List +### Tujuan +Lihat semua sales order. + +### Kolom +- sales no +- customer +- customer bank +- sales date +- grand total +- costing total +- gross margin +- status +- actions + +--- + +## 15. Sales Form +### Tujuan +Membuat order penjualan. + +### Header section +- customer select +- customer bank info preview +- sales date +- notes + +### Line section +- item type +- grade +- qty +- unit +- selling price +- subtotal + +### Summary +- grand total + +### Actions +- save draft +- continue to allocation + +--- + +## 16. Allocation Screen +### Tujuan +Memecah kebutuhan sales line ke beberapa lot. + +### Left panel +- sales line summary +- qty required +- qty allocated +- qty remaining + +### Main table +- lot code +- supplier +- available qty +- unit cost +- received at +- FIFO badge +- allocate qty input + +### Bottom summary +- total allocated +- total cost +- avg cost +- validation state + +### Actions +- auto allocate +- clear allocation +- save allocation + +--- + +## 17. Picking Screen +### Tujuan +Konfirmasi pengambilan aktual. + +### Section +- sales summary +- QR scanner panel +- allocation rows + +### Row fields +- lot code +- allocated qty +- picked qty +- variance +- status + +### Actions +- confirm picking +- report variance + +--- + +## 18. Barcode Lookup Screen +### Tujuan +Lookup cepat setelah scan. + +### Section +- scanner +- manual input +- result summary card +- trace summary +- quick actions + +### Quick actions +- open lot detail +- transfer +- hold/release +- print label + +--- + +## 19. Adjustment Form +### Tujuan +Input adjustment dan shrinkage. + +### Fields +- lot +- lot snapshot +- adjustment type +- reason +- qty before +- qty change +- qty after preview +- cost impact preview +- notes + +### Actions +- save draft +- submit / post + +--- + +## 20. Returns Screens +### Sales Return +- sales ref +- customer +- item lines +- lot ref jika ada +- qty return +- condition +- resolution + +### Purchase Return +- purchase ref +- supplier +- lot ref +- qty return +- cost +- notes + +--- + +## 21. Reports +### Layout umum +- report header +- filter bar +- summary cards +- chart section +- data table +- export buttons + +### Report yang perlu didesain +- stock summary +- stock lots +- purchase history +- sales margin +- supplier quality +- shrinkage +- traceability + +--- + +## 22. Mobile-Specific Screens +Prioritas mobile: +- barcode scan +- receipt quick input +- transfer lot +- picking +- lot lookup + +### Design notes mobile +- large touch targets +- sticky action button +- simplified data layout +- bottom sheet for actions + +--- + +## 23. UI States yang Harus Didukung +Untuk semua screen penting, designer perlu siapkan: +- empty state +- loading state +- error state +- success toast/dialog +- permission denied state +- no result state + +--- + +## 24. Penutup +Dokumen ini dimaksudkan agar desainer bisa langsung membuat layout detail per screen di Figma, lengkap dengan section, field, table columns, dan interaction priority. \ No newline at end of file diff --git a/docs/project-spec/walet-seed-data.sql b/docs/project-spec/walet-seed-data.sql new file mode 100644 index 0000000..318757b --- /dev/null +++ b/docs/project-spec/walet-seed-data.sql @@ -0,0 +1,125 @@ +INSERT INTO roles (code, name) VALUES +('OWNER', 'Owner'), +('PURCHASING', 'Admin Purchasing'), +('WAREHOUSE', 'Admin Gudang'), +('QC', 'Tim Sortasi'), +('SALES', 'Admin Sales'); + +INSERT INTO users (role_id, name, email, phone) VALUES +(1, 'Owner Walet', 'owner@walet.local', '081200000001'), +(2, 'Purchasing Walet', 'purchasing@walet.local', '081200000002'), +(3, 'Warehouse Walet', 'warehouse@walet.local', '081200000003'), +(4, 'QC Walet', 'qc@walet.local', '081200000004'), +(5, 'Sales Walet', 'sales@walet.local', '081200000005'); + +INSERT INTO units (code, name) VALUES +('KG', 'Kilogram'), +('PCS', 'Pieces'); + +INSERT INTO item_types (code, name, description) VALUES +('JNS-A', 'Jenis A', 'Jenis sarang walet A'), +('JNS-B', 'Jenis B', 'Jenis sarang walet B'), +('JNS-C', 'Jenis C', 'Jenis sarang walet C'); + +INSERT INTO item_grades (code, name, rank_order, description) VALUES +('GRD-A', 'Grade A', 1, 'Grade tertinggi'), +('GRD-B', 'Grade B', 2, 'Grade menengah'), +('GRD-C', 'Grade C', 3, 'Grade bawah'), +('REJECT', 'Reject', 99, 'Tidak layak jual'); + +INSERT INTO warehouses (code, name, address) VALUES +('WH-PUSAT', 'Gudang Pusat', 'Jl. Gudang Pusat'), +('WH-CBG1', 'Gudang Cabang 1', 'Jl. Cabang 1'); + +INSERT INTO warehouse_locations (warehouse_id, code, name, location_type) VALUES +(1, 'A1', 'Rak A1', 'RACK'), +(1, 'A2', 'Rak A2', 'RACK'), +(1, 'SORT', 'Area Sortasi', 'PROCESS'), +(2, 'B1', 'Rak B1', 'RACK'); + +INSERT INTO adjustment_reasons (code, name, category) VALUES +('SHRINK', 'Shrinkage', 'SHRINKAGE'), +('DMG', 'Damage', 'DAMAGE'), +('REGRADE', 'Regrade', 'REGRADE'), +('OPNAME', 'Stock Opname Selisih', 'ADJUSTMENT'); + +INSERT INTO suppliers (code, name, phone, email, bank_name, bank_account_number, address) VALUES +('SUP-A', 'Supplier A', '081310000001', 'sup-a@walet.local', 'BCA', '1234567890', 'Bogor'), +('SUP-B', 'Supplier B', '081310000002', 'sup-b@walet.local', 'Mandiri', '9876543210', 'Bandung'); + +INSERT INTO customers (code, name, phone, email, bank_name, bank_account_number, address) VALUES +('CUST-A', 'Customer A', '081320000001', 'cust-a@walet.local', 'BRI', '111222333444', 'Jakarta'), +('CUST-B', 'Customer B', '081320000002', 'cust-b@walet.local', 'BNI', '555666777888', 'Surabaya'); + +INSERT INTO purchases (purchase_no, supplier_id, purchase_date, supplier_invoice_no, status, created_by) +VALUES +('PO-20260428-001', 1, '2026-04-28', 'INV-SUP-A-001', 'SUBMITTED', 2), +('PO-20260428-002', 2, '2026-04-28', 'INV-SUP-B-001', 'SUBMITTED', 2); + +INSERT INTO purchase_lines (purchase_id, item_type_id, item_grade_id, qty_ordered, unit_id, unit_price, subtotal, classification_status) +VALUES +(1, 1, 1, 50.000, 1, 18000000, 900000000, 'FINAL'), +(1, 1, 2, 20.000, 1, 16000000, 320000000, 'FINAL'), +(2, 1, NULL, 40.000, 1, 17500000, 700000000, 'PROVISIONAL'); + +INSERT INTO receipts (receipt_no, purchase_id, supplier_id, receipt_date, status, received_by) +VALUES +('RCV-20260428-001', 1, 1, '2026-04-28', 'FINALIZED', 3), +('RCV-20260428-002', 2, 2, '2026-04-28', 'FINALIZED', 3); + +INSERT INTO receipt_lines (receipt_id, purchase_line_id, item_type_id, item_grade_id, qty_received, qty_accepted, qty_rejected, unit_id, unit_cost) +VALUES +(1, 1, 1, 1, 50.000, 50.000, 0, 1, 18000000), +(1, 2, 1, 2, 20.000, 20.000, 0, 1, 16000000), +(2, 3, 1, NULL, 40.000, 40.000, 0, 1, 17500000); + +INSERT INTO inventory_lots ( + lot_code, parent_lot_id, source_type, source_ref_id, supplier_id, purchase_id, purchase_line_id, receipt_id, receipt_line_id, + item_type_id, item_grade_id, warehouse_id, warehouse_location_id, original_qty, available_qty, unit_id, unit_cost, received_at, status, qr_code_value, barcode_value +) VALUES +('LOT-260428-SUPA-001', NULL, 'PURCHASE', 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 50.000, 30.000, 1, 18000000, '2026-04-28 09:00:00', 'ACTIVE', 'LOT-260428-SUPA-001', 'LOT-260428-SUPA-001'), +('LOT-260428-SUPA-002', NULL, 'PURCHASE', 2, 1, 1, 2, 1, 2, 1, 2, 1, 2, 20.000, 20.000, 1, 16000000, '2026-04-28 09:10:00', 'ACTIVE', 'LOT-260428-SUPA-002', 'LOT-260428-SUPA-002'), +('LOT-260428-SUPB-001', NULL, 'PURCHASE', 3, 2, 2, 3, 2, 3, 1, 2, 1, 3, 40.000, 0.000, 1, 17500000, '2026-04-28 10:00:00', 'CLOSED', 'LOT-260428-SUPB-001', 'LOT-260428-SUPB-001'), +('LOT-260428-SUPB-001-S1', 3, 'SORTING', 1, 2, 2, 3, 2, 3, 1, 1, 1, 1, 18.000, 8.000, 1, 17500000, '2026-04-28 11:00:00', 'ACTIVE', 'LOT-260428-SUPB-001-S1', 'LOT-260428-SUPB-001-S1'), +('LOT-260428-SUPB-001-S2', 3, 'SORTING', 1, 2, 2, 3, 2, 3, 1, 2, 1, 2, 12.000, 12.000, 1, 17500000, '2026-04-28 11:00:00', 'ACTIVE', 'LOT-260428-SUPB-001-S2', 'LOT-260428-SUPB-001-S2'), +('LOT-260428-SUPB-001-S3', 3, 'SORTING', 1, 2, 2, 3, 2, 3, 2, 1, 1, 2, 7.000, 7.000, 1, 17500000, '2026-04-28 11:00:00', 'ACTIVE', 'LOT-260428-SUPB-001-S3', 'LOT-260428-SUPB-001-S3'); + +INSERT INTO sorting_sessions (sorting_no, source_lot_id, sorting_date, input_qty, output_qty, shrinkage_qty, notes, sorted_by) +VALUES +('SRT-20260428-001', 3, '2026-04-28 11:00:00', 40.000, 37.000, 3.000, 'Sortasi batch campuran supplier B', 4); + +INSERT INTO sorting_results (sorting_session_id, result_lot_id, item_type_id, item_grade_id, qty_result, unit_cost) +VALUES +(1, 4, 1, 1, 18.000, 17500000), +(1, 5, 1, 2, 12.000, 17500000), +(1, 6, 2, 1, 7.000, 17500000); + +INSERT INTO sales (sales_no, customer_id, sales_date, status, created_by) +VALUES +('SLS-20260428-001', 1, '2026-04-28', 'CONFIRMED', 5); + +INSERT INTO sales_lines (sales_id, item_type_id, item_grade_id, qty_sold, unit_id, selling_price, subtotal, costing_total, gross_margin) +VALUES +(1, 1, 1, 30.000, 1, 22000000, 660000000, 550000000, 110000000); + +INSERT INTO sales_allocations (sales_line_id, inventory_lot_id, qty_allocated, unit_cost, total_cost, allocated_at, allocated_by) +VALUES +(1, 1, 20.000, 18000000, 360000000, '2026-04-28 13:00:00', 5), +(1, 4, 10.000, 19000000, 190000000, '2026-04-28 13:00:00', 5); + +INSERT INTO inventory_movements (movement_no, movement_type, inventory_lot_id, related_lot_id, warehouse_id, warehouse_location_id, qty_in, qty_out, balance_after, unit_cost, reference_type, reference_id, movement_date, created_by, notes) +VALUES +('MOV-0001', 'RECEIPT', 1, NULL, 1, 1, 50.000, 0.000, 50.000, 18000000, 'RECEIPT', 1, '2026-04-28 09:00:00', 3, 'Receipt lot supplier A grade A'), +('MOV-0002', 'RECEIPT', 2, NULL, 1, 2, 20.000, 0.000, 20.000, 16000000, 'RECEIPT', 1, '2026-04-28 09:10:00', 3, 'Receipt lot supplier A grade B'), +('MOV-0003', 'RECEIPT', 3, NULL, 1, 3, 40.000, 0.000, 40.000, 17500000, 'RECEIPT', 2, '2026-04-28 10:00:00', 3, 'Receipt lot provisional supplier B'), +('MOV-0004', 'SORT_OUT', 3, NULL, 1, 3, 0.000, 40.000, 0.000, 17500000, 'SORTING', 1, '2026-04-28 11:00:00', 4, 'Lot sumber keluar untuk sortasi'), +('MOV-0005', 'SORT_IN', 4, 3, 1, 1, 18.000, 0.000, 18.000, 17500000, 'SORTING', 1, '2026-04-28 11:00:00', 4, 'Hasil sortasi grade A'), +('MOV-0006', 'SORT_IN', 5, 3, 1, 2, 12.000, 0.000, 12.000, 17500000, 'SORTING', 1, '2026-04-28 11:00:00', 4, 'Hasil sortasi grade B'), +('MOV-0007', 'SORT_IN', 6, 3, 1, 2, 7.000, 0.000, 7.000, 17500000, 'SORTING', 1, '2026-04-28 11:00:00', 4, 'Hasil sortasi jenis B grade A'), +('MOV-0008', 'SALE_OUT', 1, NULL, 1, 1, 0.000, 20.000, 30.000, 18000000, 'SALE', 1, '2026-04-28 13:00:00', 5, 'Alokasi penjualan dari lot supplier A'), +('MOV-0009', 'SALE_OUT', 4, NULL, 1, 1, 0.000, 10.000, 8.000, 19000000, 'SALE', 1, '2026-04-28 13:00:00', 5, 'Alokasi penjualan dari lot hasil sortasi supplier B'); + +INSERT INTO lot_labels (inventory_lot_id, label_type, label_value, printed_at, printed_by, print_count, status) +VALUES +(1, 'QR', 'LOT-260428-SUPA-001', '2026-04-28 09:01:00', 3, 1, 'ACTIVE'), +(4, 'QR', 'LOT-260428-SUPB-001-S1', '2026-04-28 11:01:00', 4, 1, 'ACTIVE'); \ No newline at end of file diff --git a/docs/project-spec/walet-sprint-breakdown.md b/docs/project-spec/walet-sprint-breakdown.md new file mode 100644 index 0000000..9b7e545 --- /dev/null +++ b/docs/project-spec/walet-sprint-breakdown.md @@ -0,0 +1,281 @@ +# Task Breakdown Per Sprint Sistem Inventory Walet + +## Asumsi +- Sprint 2 minggu +- Tim kecil sampai menengah +- Fokus awal ke fondasi backend + frontend operasional inti +- Target awal: MVP usable untuk pembelian, receiving, stok lot, dan penjualan sederhana + +## Sprint 0 - Foundation Setup +### Backend +- setup project structure +- setup database PostgreSQL +- implement auth dasar +- implement migration system +- import schema awal + +### Frontend +- setup Next.js + TypeScript +- setup UI base, app shell, routing +- setup TanStack Query, form library, state management + +### DevOps +- environment dev/staging +- seed data awal +- lint, formatting, pre-commit + +### Deliverables +- repo siap kerja +- database hidup +- login dasar +- layout aplikasi hidup + +--- + +## Sprint 1 - Master Data Core +### Backend +- API suppliers +- API customers +- API item types +- API item grades +- API warehouses dan locations +- API adjustment reasons + +### Frontend +- halaman list/create/edit master data +- reusable form dan table + +### QA +- validasi CRUD master data + +### Deliverables +- semua master data inti bisa dikelola + +--- + +## Sprint 2 - Purchasing +### Backend +- API purchases +- API purchase lines +- status workflow draft/submitted/cancelled +- histori harga beli dasar + +### Frontend +- purchase list +- purchase form multi-line +- purchase detail + +### QA +- test multi-line purchase +- test status pembelian + +### Deliverables +- user bisa membuat pembelian multi jenis-grade + +--- + +## Sprint 3 - Receiving dan Lot Creation +### Backend +- API receipts +- API receipt lines +- generate inventory lots +- movement ledger untuk receiving +- print label metadata + +### Frontend +- receipt form +- receipt detail +- generate lot action +- lot list dasar + +### QA +- test qty ordered vs received +- test lot terbentuk benar + +### Deliverables +- barang bisa diterima dan jadi lot + +--- + +## Sprint 4 - Inventory Lot Management +### Backend +- API lot detail +- API lot movement history +- API hold/release +- API transfer lot +- stock summary endpoint + +### Frontend +- stock summary page +- stock lot list +- lot detail +- transfer/hold action + +### QA +- test trace lot dasar +- test lot status + +### Deliverables +- user bisa melihat dan mengelola lot + +--- + +## Sprint 5 - Sales dan Allocation +### Backend +- API sales +- API sales lines +- API manual allocation +- API auto allocation FIFO +- costing by allocation +- movement ledger untuk sales out + +### Frontend +- sales form +- allocation screen +- sales detail +- costing preview + +### QA +- test sales dari satu lot +- test sales dari banyak lot +- test costing total + +### Deliverables +- penjualan campuran dari beberapa lot berjalan + +--- + +## Sprint 6 - Picking dan Barcode Lookup +### Backend +- API confirm picking +- API barcode lookup +- API print/reprint label + +### Frontend +- picking screen +- QR lookup page +- scan-to-lot workflow + +### QA +- test scan lot +- test picking actual qty + +### Deliverables +- gudang bisa picking pakai scan + +--- + +## Sprint 7 - Sorting, Regrade, Shrinkage +### Backend +- API sorting session +- API sorting results +- parent-child lot relation +- API regrade +- API stock adjustment/shrinkage + +### Frontend +- sorting session form +- regrade form +- adjustment form +- movement timeline update + +### QA +- test lot pecah jadi child lots +- test shrinkage +- test regrade + +### Deliverables +- sortasi dan regrade operasional berjalan + +--- + +## Sprint 8 - Returns dan Traceability +### Backend +- API sales returns +- API purchase returns +- traceability report endpoint +- forward trace dan backward trace + +### Frontend +- return pages +- traceability report page +- lot usage per sales +- sales source per lot + +### QA +- test trace dari sales ke supplier +- test trace dari supplier ke customer + +### Deliverables +- audit traceability end-to-end hidup + +--- + +## Sprint 9 - Reporting dan Hardening +### Backend +- reports stock summary +- stock lots +- purchases +- sales +- margins +- shrinkage +- supplier quality + +### Frontend +- report pages +- export actions +- dashboard refinement + +### QA +- performance testing +- data consistency testing +- permission testing + +### Deliverables +- MVP lengkap dan siap UAT + +--- + +## Sprint 10 - UAT & Stabilization +### Fokus +- bug fixing +- UX refinement +- validasi flow riil user +- tuning laporan dan filter +- training material awal + +### Deliverables +- versi siap pilot + +--- + +## Jalur MVP paling ringkas kalau mau dipercepat +Kalau Pak Wira ingin versi lebih cepat, bisa ambil jalur: +1. Sprint 0 +2. Sprint 1 +3. Sprint 2 +4. Sprint 3 +5. Sprint 4 +6. Sprint 5 + +Artinya MVP cepat sudah mencakup: +- master data +- purchase +- receipt +- lot inventory +- stock summary +- sales allocation +- costing dasar + +Sortasi, regrade, return, dan trace report advanced bisa menyusul. + +## Rekomendasi prioritas tertinggi +Kalau tim terbatas, jangan mulai dari laporan dulu. Prioritas paling penting: +1. lot model benar +2. movement ledger benar +3. sales allocation benar +4. costing benar +5. baru reporting dan polish + +## Kesimpulan +Task breakdown ini dibuat supaya tim bisa membangun sistem walet secara bertahap tanpa kehilangan fondasi. Pusat keberhasilan ada pada lot, allocation, movement ledger, dan traceability. \ No newline at end of file diff --git a/docs/project-spec/walet-ui-data-contract.md b/docs/project-spec/walet-ui-data-contract.md new file mode 100644 index 0000000..151d049 --- /dev/null +++ b/docs/project-spec/walet-ui-data-contract.md @@ -0,0 +1,455 @@ +# UI Data Contract Frontend-Backend Sistem Inventory Walet + +## 1. Tujuan +Dokumen ini menjelaskan kontrak data utama antara frontend dan backend agar implementasi UI tidak salah tafsir, terutama pada area lot, allocation, costing, sorting, dan traceability. + +## 2. Prinsip Umum +- backend adalah source of truth untuk data transaksi +- frontend boleh menyimpan draft workflow lokal sebelum submit +- angka quantity dan cost harus selalu dikirim dalam bentuk numerik +- ID utama menggunakan integer/bigint +- status harus dikirim dalam kode string yang konsisten +- field read-only harus dibedakan jelas dari field editable + +## 3. Format Response Umum +Contoh standar response list: +```json +{ + "data": [], + "meta": { + "page": 1, + "per_page": 20, + "total": 100 + } +} +``` + +Contoh standar response detail: +```json +{ + "data": { + "id": 1 + } +} +``` + +Contoh error: +```json +{ + "message": "Validation error", + "errors": { + "qty": ["qty harus lebih besar dari 0"] + } +} +``` + +## 4. Purchase Contract +### Purchase list item +```json +{ + "id": 1, + "purchase_no": "PO-20260428-001", + "supplier": { + "id": 1, + "name": "Supplier A", + "bank_name": "BCA", + "bank_account_number": "1234567890" + }, + "purchase_date": "2026-04-28", + "status": "SUBMITTED", + "line_count": 3, + "grand_total": 1220000000 +} +``` + +### Purchase detail +```json +{ + "id": 1, + "purchase_no": "PO-20260428-001", + "supplier": { + "id": 1, + "name": "Supplier A", + "bank_name": "BCA", + "bank_account_number": "1234567890" + }, + "purchase_date": "2026-04-28", + "supplier_invoice_no": "INV-SUP-A-001", + "status": "SUBMITTED", + "notes": "Pembelian campuran", + "lines": [ + { + "id": 1, + "item_type": { "id": 1, "name": "Jenis A" }, + "item_grade": { "id": 1, "name": "Grade A" }, + "qty_ordered": 50, + "unit": { "id": 1, "code": "KG" }, + "unit_price": 18000000, + "subtotal": 900000000, + "classification_status": "FINAL" + } + ] +} +``` + +## 5. Receipt Contract +### Receipt detail +```json +{ + "id": 1, + "receipt_no": "RCV-20260428-001", + "purchase": { + "id": 1, + "purchase_no": "PO-20260428-001" + }, + "supplier": { + "id": 1, + "name": "Supplier A", + "bank_name": "BCA", + "bank_account_number": "1234567890" + }, + "receipt_date": "2026-04-28", + "status": "FINALIZED", + "lines": [ + { + "id": 1, + "purchase_line_id": 1, + "item_type": { "id": 1, "name": "Jenis A" }, + "item_grade": { "id": 1, "name": "Grade A" }, + "qty_received": 50, + "qty_accepted": 50, + "qty_rejected": 0, + "unit_cost": 18000000, + "warehouse": { "id": 1, "name": "Gudang Pusat" }, + "location": { "id": 1, "name": "Rak A1" } + } + ], + "generated_lots": [ + { + "id": 1, + "lot_code": "LOT-260428-SUPA-001", + "status": "ACTIVE" + } + ] +} +``` + +## 6. Lot Contract +### Lot list item +```json +{ + "id": 1, + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "item_type": "Jenis A", + "item_grade": "Grade A", + "original_qty": 50, + "available_qty": 30, + "unit_cost": 18000000, + "warehouse": "Gudang Pusat", + "location": "Rak A1", + "aging_days": 3, + "status": "ACTIVE" +} +``` + +### Lot detail +```json +{ + "id": 1, + "lot_code": "LOT-260428-SUPA-001", + "supplier": { + "id": 1, + "name": "Supplier A", + "bank_name": "BCA", + "bank_account_number": "1234567890" + }, + "item_type": { + "id": 1, + "name": "Jenis A" + }, + "item_grade": { + "id": 1, + "name": "Grade A" + }, + "original_qty": 50, + "available_qty": 30, + "reserved_qty": 0, + "damaged_qty": 0, + "shrinkage_qty": 0, + "unit_cost": 18000000, + "warehouse": { + "id": 1, + "name": "Gudang Pusat" + }, + "location": { + "id": 1, + "name": "Rak A1" + }, + "status": "ACTIVE", + "parent_lot": null, + "child_lots": [], + "labels": [ + { + "type": "QR", + "value": "LOT-260428-SUPA-001" + } + ] +} +``` + +## 7. Sales Contract +### Sales list item +```json +{ + "id": 1, + "sales_no": "SLS-20260428-001", + "customer": { + "id": 1, + "name": "Customer A", + "bank_name": "BRI", + "bank_account_number": "111222333444" + }, + "sales_date": "2026-04-28", + "status": "CONFIRMED", + "grand_total": 660000000, + "costing_total": 550000000, + "gross_margin": 110000000 +} +``` + +### Sales detail +```json +{ + "id": 1, + "sales_no": "SLS-20260428-001", + "customer": { + "id": 1, + "name": "Customer A", + "bank_name": "BRI", + "bank_account_number": "111222333444" + }, + "sales_date": "2026-04-28", + "status": "CONFIRMED", + "lines": [ + { + "id": 1, + "item_type": { "id": 1, "name": "Jenis A" }, + "item_grade": { "id": 1, "name": "Grade A" }, + "qty_sold": 30, + "selling_price": 22000000, + "subtotal": 660000000, + "costing_total": 550000000, + "gross_margin": 110000000, + "allocations": [ + { + "lot_id": 1, + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "qty_allocated": 20, + "unit_cost": 18000000, + "total_cost": 360000000 + }, + { + "lot_id": 4, + "lot_code": "LOT-260428-SUPB-001-S1", + "supplier": "Supplier B", + "qty_allocated": 10, + "unit_cost": 19000000, + "total_cost": 190000000 + } + ] + } + ] +} +``` + +## 8. Allocation Screen Contract +### Available lots response +```json +{ + "data": [ + { + "lot_id": 1, + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "available_qty": 30, + "unit_cost": 18000000, + "received_at": "2026-04-28T09:00:00Z", + "is_fifo_recommended": true, + "status": "ACTIVE" + }, + { + "lot_id": 4, + "lot_code": "LOT-260428-SUPB-001-S1", + "supplier": "Supplier B", + "available_qty": 8, + "unit_cost": 19000000, + "received_at": "2026-04-28T11:00:00Z", + "is_fifo_recommended": false, + "status": "ACTIVE" + } + ] +} +``` + +### Allocation submit payload +```json +{ + "lines": [ + { + "sales_line_id": 1, + "allocations": [ + { + "inventory_lot_id": 1, + "qty_allocated": 20 + }, + { + "inventory_lot_id": 4, + "qty_allocated": 10 + } + ] + } + ] +} +``` + +### Allocation summary response +```json +{ + "sales_line_id": 1, + "qty_required": 30, + "qty_allocated": 30, + "costing_total": 550000000, + "avg_cost": 18333333.33, + "is_complete": true +} +``` + +## 9. Sorting Contract +### Sorting session detail +```json +{ + "id": 1, + "sorting_no": "SRT-20260428-001", + "source_lot": { + "id": 3, + "lot_code": "LOT-260428-SUPB-001" + }, + "input_qty": 40, + "output_qty": 37, + "shrinkage_qty": 3, + "results": [ + { + "lot_id": 4, + "lot_code": "LOT-260428-SUPB-001-S1", + "item_type": "Jenis A", + "item_grade": "Grade A", + "qty_result": 18 + } + ] +} +``` + +## 10. Barcode Lookup Contract +```json +{ + "lot_id": 1, + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "item_type": "Jenis A", + "grade": "Grade A", + "available_qty": 30, + "warehouse": "Gudang Pusat", + "location": "Rak A1", + "status": "ACTIVE", + "actions": { + "can_hold": true, + "can_release": false, + "can_transfer": true, + "can_pick": true + } +} +``` + +## 11. Report Contract +### Stock summary row +```json +{ + "item_type": "Jenis A", + "item_grade": "Grade A", + "warehouse": "Gudang Pusat", + "qty_total": 38, + "inventory_value": 730000000, + "active_lot_count": 2 +} +``` + +### Traceability row +```json +{ + "sales_no": "SLS-20260428-001", + "customer": "Customer A", + "item": "Jenis A / Grade A", + "qty_sold": 30, + "sources": [ + { + "lot_code": "LOT-260428-SUPA-001", + "supplier": "Supplier A", + "qty": 20, + "purchase_no": "PO-20260428-001" + } + ] +} +``` + +## 12. Frontend Editable vs Readonly Rules +### Editable examples +- qty_received +- qty_accepted +- selling_price +- qty_allocated draft +- qty_picked actual +- notes + +### Readonly examples +- lot_code +- costing_total final hasil backend +- gross_margin final hasil backend +- parent_lot relation +- source trace fields + +## 13. Status Codes yang Harus Konsisten +### Purchase +- DRAFT +- SUBMITTED +- CANCELLED + +### Receipt +- DRAFT +- FINALIZED + +### Lot +- ACTIVE +- HOLD +- CLOSED +- REJECTED + +### Sales +- DRAFT +- ALLOCATED +- PICKED +- CONFIRMED +- CANCELLED + +## 14. UI Validation Expectations +Frontend harus validasi cepat untuk: +- qty > 0 +- total allocation sama dengan qty line +- qty result sorting tidak melebihi input +- required fields tidak kosong + +Namun final validation tetap di backend. + +## 15. Kesimpulan +Kontrak data ini dibuat agar frontend dan backend bicara bahasa yang sama, khususnya pada lot, allocation, sorting, traceability, dan costing yang merupakan inti sistem walet ini. \ No newline at end of file diff --git a/docs/project-spec/walet-user-flow-visual-for-figma.md b/docs/project-spec/walet-user-flow-visual-for-figma.md new file mode 100644 index 0000000..eeaea9e --- /dev/null +++ b/docs/project-spec/walet-user-flow-visual-for-figma.md @@ -0,0 +1,331 @@ +# User Flow Visual untuk Figma +## Sistem Inventory Sarang Burung Walet + +Dokumen ini dibuat agar desainer bisa langsung menggambar user flow visual di Figma atau FigJam. + +--- + +# 1. Node Legend yang Disarankan +Gunakan standar shape berikut di Figma/FigJam: + +- **Rectangle** = screen/page +- **Rounded rectangle** = modal/drawer/panel aksi +- **Diamond** = decision point +- **Parallelogram** = input/scanning/manual entry +- **Small tag/badge** = status atau note penting +- **Arrow** = alur utama +- **Dashed arrow** = alur alternatif / exception flow + +Warna saran: +- Biru = flow utama +- Hijau = success path +- Oranye = warning/manual override +- Merah = error/blocked state +- Ungu = traceability / lookup path + +--- + +# 2. Sitemap Ringkas +Susun dari kiri ke kanan: + +```text +Login + -> Dashboard + -> Master Data + -> Suppliers + -> Customers + -> Item Types + -> Item Grades + -> Warehouses + -> Purchases + -> Receipts + -> Lots + -> Sorting + -> Sales + -> Adjustments + -> Returns + -> Barcode Lookup + -> Reports +``` + +--- + +# 3. Flow 1. Purchase to Receipt to Lot +## Tujuan +Menggambarkan alur dari pembelian sampai lot terbentuk. + +## Node urutan +```text +Dashboard + -> Purchase List + -> Purchase Form + -> [Decision: Save Draft or Submit?] + -> Save Draft -> Purchase Detail + -> Submit -> Purchase Detail + -> Create Receipt + -> Receipt Form + -> [Decision: Finalize Receipt?] + -> No -> Draft Receipt Detail + -> Yes -> Generate Lots + -> Receipt Detail + -> Lot Detail +``` + +## Notes untuk designer +- tampilkan supplier info termasuk bank info di purchase/receipt +- receipt punya branch untuk accepted/rejected qty +- setelah finalize, harus ada cabang ke lot result + +--- + +# 4. Flow 2. Lot to Sorting / Regrade +## Tujuan +Menggambarkan alur perubahan lot setelah QC/sortasi. + +## Node urutan +```text +Dashboard + -> Lot List + -> Lot Detail + -> [Decision: Need Sorting or Regrade?] + -> Sorting + -> Sorting Session Form + -> [Decision: Output valid?] + -> No -> Error / revise rows + -> Yes -> Child Lots Created + -> Sorting Result Detail + -> Child Lot Detail + -> Regrade + -> Regrade Form + -> [Decision: Qty valid?] + -> No -> Error + -> Yes -> New Grade Lot / Updated Lot + -> Lot Detail +``` + +## Notes +- decision node harus jelas memisahkan sorting dan regrade +- gunakan warna oranye untuk warning jika qty tidak balance +- gunakan node kecil untuk menandai shrinkage/reject + +--- + +# 5. Flow 3. Sales Order to Allocation to Picking +## Tujuan +Ini flow paling penting untuk desain, karena inti kompleksitas sistem ada di sini. + +## Node urutan +```text +Dashboard + -> Sales List + -> Sales Form + -> Sales Detail + -> Allocation Screen + -> [Decision: Auto allocate or Manual allocate?] + -> Auto Allocate FIFO + -> Manual Select Lots + -> [Decision: Allocation complete?] + -> No -> Back to Allocation Screen + -> Yes -> Save Allocation + -> Picking Screen + -> [Decision: Picked qty matches allocation?] + -> Yes -> Confirm Picking + -> No -> Variance Flow + -> Sales Detail Final +``` + +## Variance subflow +```text +Picking Screen + -> Variance Warning + -> [Decision: allow override?] + -> No -> Back to Picking + -> Yes -> Save Variance + Audit + -> Sales Detail Final +``` + +## Notes +- allocation page perlu ditandai sebagai "critical workflow" +- tampilkan alur campuran lot dari beberapa supplier +- gunakan dashed flow untuk manual override + +--- + +# 6. Flow 4. Barcode / QR Lookup +## Tujuan +Flow cepat untuk user gudang. + +## Node urutan +```text +Dashboard + -> Barcode Scan Page + -> [Decision: Scan success?] + -> No -> Manual Input + -> Yes -> Lookup Result + -> [Decision: What next?] + -> Open Lot Detail + -> Transfer Lot + -> Hold/Release Lot + -> Picking Context +``` + +## Notes +- flow ini sebaiknya punya versi mobile frame +- scan success dan scan fail harus digambar + +--- + +# 7. Flow 5. Stock Adjustment / Shrinkage +## Tujuan +Menggambarkan flow perubahan stok di luar transaksi normal. + +## Node urutan +```text +Dashboard + -> Lot Detail / Adjustment Menu + -> Adjustment Form + -> [Decision: Above approval threshold?] + -> No -> Post Adjustment + -> Yes -> Pending Approval + -> Approver Review + -> [Decision: Approve?] + -> Reject -> Back to Draft/Cancelled + -> Approve -> Post Adjustment + -> Updated Lot Detail + -> Movement Ledger Updated +``` + +## Notes +- cocok digambar dengan lane approval terpisah +- gunakan merah untuk shrinkage/loss branch + +--- + +# 8. Flow 6. Sales Return +## Node urutan +```text +Sales Detail + -> Create Sales Return + -> Sales Return Form + -> [Decision: Return linked to source lot?] + -> Yes -> Return to existing lot / controlled flow + -> No -> Create return lot + -> [Decision: Condition good?] + -> Yes -> Restock + -> No -> Regrade / Reject + -> Return Detail +``` + +--- + +# 9. Flow 7. Purchase Return +## Node urutan +```text +Purchase Detail / Lot Detail + -> Create Purchase Return + -> Purchase Return Form + -> Confirm Return + -> Lot Reduced + -> Movement Ledger Updated +``` + +--- + +# 10. Flow 8. Traceability Lookup +## Tujuan +Membantu designer memahami bagaimana user menelusuri asal barang. + +## Backward trace +```text +Sales Detail + -> Sales Line + -> Allocation Rows + -> Lot Detail + -> Receipt Detail + -> Purchase Detail + -> Supplier Detail +``` + +## Forward trace +```text +Supplier Detail + -> Purchase List + -> Purchase Detail + -> Lots Created + -> Sales Allocations + -> Sales Detail + -> Customer Detail +``` + +## Notes +- trace flow bisa diberi warna ungu +- cocok dibuat sebagai separate flow map + +--- + +# 11. Rekomendasi Layout Frame di Figma/FigJam +## Board 1. Sitemap +- high-level only + +## Board 2. Core Transaction Flows +- purchase -> receipt -> lot +- lot -> sorting/regrade +- sales -> allocation -> picking + +## Board 3. Supporting Flows +- scan lookup +- adjustment +- return +- traceability + +## Board 4. Role Overlay +Tambahkan badge kecil pada node untuk role: +- OWN +- PUR +- WH +- QC +- SAL + +--- + +# 12. Rekomendasi Label per Screen +Gunakan format: +- `Screen: Purchase Form` +- `Decision: Allocation complete?` +- `Modal: Hold Lot Confirmation` +- `Exception: Pick qty variance` + +Ini bikin flow mudah dibaca saat handoff. + +--- + +# 13. Rekomendasi untuk Prototype Link +Untuk prototype yang perlu diklik duluan: +1. Dashboard +2. Purchase Form +3. Receipt Form +4. Lot Detail +5. Sales Form +6. Allocation Screen +7. Picking Screen +8. Barcode Scan + +--- + +# 14. Deliverable yang Disarankan dari Designer +Setelah menggambar flow ini, output minimal: +- 1 figjam board user flow +- 1 screen map desktop +- 1 screen map mobile operasional +- clickable prototype untuk sales allocation flow + +--- + +# 15. Penutup +Flow yang paling penting untuk digambar dulu adalah: +- purchase -> receipt -> lot +- lot -> sorting/regrade +- sales -> allocation -> picking +- scan -> lookup -> action + +Kalau empat flow ini sudah jelas di Figma/FigJam, desainer akan jauh lebih cepat masuk ke desain screen detail. \ No newline at end of file diff --git a/docs/project-spec/walet-wireframe.html b/docs/project-spec/walet-wireframe.html new file mode 100644 index 0000000..3ed78b8 --- /dev/null +++ b/docs/project-spec/walet-wireframe.html @@ -0,0 +1,428 @@ +
# Wireframe Layar Aplikasi Sistem Inventory Walet
+
+## 1. Dashboard
+### Tujuan
+Memberikan gambaran cepat kondisi bisnis.
+
+### Komponen
+- Total stok aktif
+- Nilai inventory
+- Pembelian bulan ini
+- Penjualan bulan ini
+- Susut bulan ini
+- Top 5 supplier
+- Top 5 customer
+- Lot aging alert
+- Lot hold alert
+- Shortcut ke Purchase, Receiving, Sales, Inventory
+
+### Widget utama
+- Stok per jenis-grade
+- Margin per periode
+- Grafik pembelian vs penjualan
+- Susut per supplier
+
+---
+
+## 2. Purchase List
+### Tujuan
+Melihat semua transaksi pembelian.
+
+Catatan master terkait:
+- data supplier perlu menyimpan nama bank dan nomor rekening untuk kebutuhan pembayaran
+
+### Komponen
+- Filter tanggal
+- Filter supplier
+- Filter status
+- Tombol buat pembelian baru
+- Tabel:
+  - nomor pembelian
+  - supplier
+  - tanggal
+  - total
+  - status
+  - jumlah line
+  - aksi detail/edit
+
+---
+
+## 3. Purchase Form
+### Tujuan
+Membuat transaksi pembelian multi jenis dan multi grade.
+
+### Header
+- Purchase no
+- Supplier
+- Tanggal pembelian
+- Invoice supplier
+- Catatan
+
+### Detail line items
+Kolom:
+- Jenis
+- Grade
+- Qty
+- Satuan
+- Harga beli
+- Subtotal
+- Status klasifikasi
+- Catatan
+
+### Aksi
+- Tambah baris
+- Simpan draft
+- Submit
+- Print
+
+---
+
+## 4. Receipt Form
+### Tujuan
+Menerima barang dari pembelian dan membuat lot.
+
+### Header
+- Receipt no
+- Referensi purchase
+- Supplier
+- Tanggal terima
+- Petugas penerima
+
+### Tabel line receiving
+- Item purchase
+- Qty ordered
+- Qty received
+- Qty accepted
+- Qty rejected
+- Unit cost
+- Status
+- Catatan
+
+### Aksi
+- Generate lot
+- Print QR label
+- Simpan receipt
+
+---
+
+## 5. Receipt Detail
+### Tujuan
+Melihat hasil penerimaan dan lot yang terbentuk.
+
+### Komponen
+- Ringkasan receipt
+- Daftar lot hasil receipt
+- Kode lot
+- Jenis
+- Grade
+- Qty
+- Gudang
+- Lokasi
+- QR status
+- Tombol print ulang label
+
+---
+
+## 6. Stock Summary
+### Tujuan
+Melihat stok ringkas per jenis-grade.
+
+### Komponen
+- Filter gudang
+- Filter jenis
+- Filter grade
+- Tabel:
+  - jenis
+  - grade
+  - qty total
+  - nilai total
+  - jumlah lot aktif
+
+---
+
+## 7. Stock Lot List
+### Tujuan
+Melihat stok detail berbasis lot.
+
+### Tabel
+- Lot code
+- Supplier
+- Jenis
+- Grade
+- Qty awal
+- Qty tersedia
+- Unit cost
+- Tanggal masuk
+- Aging
+- Status
+- Gudang
+- Lokasi
+- Aksi detail
+
+### Filter
+- supplier
+- jenis
+- grade
+- status
+- gudang
+- aging
+
+---
+
+## 8. Lot Detail
+### Tujuan
+Menjadi pusat traceability satu lot.
+
+### Section
+#### Informasi utama
+- Lot code
+- Supplier
+- Jenis
+- Grade
+- Qty awal
+- Qty available
+- Unit cost
+- Gudang
+- Lokasi
+- Status
+
+#### Parent-child relation
+- Parent lot
+- Child lots hasil sortasi/regrade
+
+#### Movement history
+- receiving
+- sorting
+- adjustment
+- transfer
+- sales allocation
+- return
+
+#### Sales usage
+- invoice mana saja yang memakai lot ini
+- qty per invoice
+
+#### Aksi
+- print ulang label
+- hold lot
+- release lot
+- pindah lokasi
+- adjustment
+- regrade
+
+---
+
+## 9. Sorting Session Form
+### Tujuan
+Memecah satu lot menjadi beberapa lot hasil sortasi.
+
+### Input utama
+- Source lot
+- Qty input
+- Tanggal sortasi
+- Operator
+- Catatan
+
+### Hasil sortasi table
+- Jenis hasil
+- Grade hasil
+- Qty hasil
+- Cost
+- Catatan
+
+### Additional
+- Qty shrinkage
+- Qty reject
+
+### Aksi
+- Generate child lots
+- Simpan sesi
+
+---
+
+## 10. Regrade Form
+### Tujuan
+Memindahkan sebagian qty dari satu grade ke grade lain.
+
+### Komponen
+- Source lot
+- Grade asal
+- Grade tujuan
+- Qty pindah
+- Alasan
+- Catatan
+
+### Output
+- lot lama berkurang
+- lot baru terbentuk atau ditambah
+
+---
+
+## 11. Sales Form
+### Tujuan
+Membuat transaksi penjualan.
+
+Catatan master terkait:
+- data customer perlu menyimpan nama bank dan nomor rekening untuk kebutuhan pembayaran/settlement
+
+### Header
+- Sales no
+- Customer
+- Tanggal
+- Catatan
+
+### Detail lines
+- Jenis
+- Grade
+- Qty
+- Satuan
+- Harga jual
+- Subtotal
+
+### Aksi
+- Simpan draft
+- Lanjut ke allocation
+- Submit
+
+---
+
+## 12. Allocation Screen
+### Tujuan
+Mengalokasikan kebutuhan penjualan ke satu atau banyak lot.
+
+### Layout
+#### Kiri
+- informasi sales line
+- jenis
+- grade
+- qty dibutuhkan
+
+#### Kanan
+- daftar lot tersedia
+  - lot code
+  - supplier
+  - qty available
+  - unit cost
+  - tanggal masuk
+  - rekomendasi FIFO
+
+### Interaksi
+- auto allocate
+- manual select qty per lot
+- validasi total allocation = qty line
+- tampil total costing hasil allocation
+
+---
+
+## 13. Picking Screen
+### Tujuan
+Memvalidasi pengambilan barang nyata dari lot teralokasi.
+
+### Komponen
+- scan QR lot
+- tampil info lot
+- qty dialokasikan
+- qty diambil aktual
+- selisih jika ada
+- konfirmasi picking selesai
+
+---
+
+## 14. Transfer Form
+### Tujuan
+Memindahkan lot antar gudang atau lokasi.
+
+### Komponen
+- scan/pilih lot
+- gudang asal
+- lokasi asal
+- gudang tujuan
+- lokasi tujuan
+- qty pindah
+- catatan
+
+---
+
+## 15. Stock Adjustment Form
+### Tujuan
+Mencatat perubahan stok selain transaksi normal.
+
+### Komponen
+- lot
+- qty before
+- qty change
+- qty after
+- jenis adjustment
+- alasan
+- cost impact
+- catatan
+
+---
+
+## 16. Barcode / QR Lookup
+### Tujuan
+Lookup cepat dari hasil scan.
+
+### Hasil scan menampilkan
+- lot code
+- supplier
+- jenis
+- grade
+- qty available
+- cost
+- lokasi
+- status
+- histori singkat
+
+### Aksi cepat
+- buka detail lot
+- print label ulang
+- pindah lokasi
+- hold/release
+
+---
+
+## 17. Reports
+### Jenis laporan
+- Pembelian
+- Penerimaan
+- Stok summary
+- Stok lot
+- Aging
+- Sortasi
+- Sales
+- Margin
+- Shrinkage
+- Supplier quality
+- Traceability
+
+### Filter umum
+- tanggal
+- supplier
+- customer
+- jenis
+- grade
+- gudang
+- status
+
+---
+
+## 18. Design Notes
+- halaman operasional harus mobile-friendly
+- allocation screen harus cepat, jangan terlalu penuh
+- lot detail harus jadi layar kunci karena pusat trace
+- scan workflow harus seminim mungkin input manual
+- warna status penting, misal active, hold, closed, rejected
+
+## 19. Prioritas Wireframe MVP
+Urutan yang sebaiknya didesain dulu:
+1. Dashboard
+2. Purchase Form
+3. Receipt Form
+4. Stock Lot List
+5. Lot Detail
+6. Sales Form
+7. Allocation Screen
+8. Picking Screen
+9. Sorting Session Form
+10. Barcode Lookup
\ No newline at end of file diff --git a/docs/project-spec/walet-wireframe.md b/docs/project-spec/walet-wireframe.md new file mode 100644 index 0000000..1fbbec9 --- /dev/null +++ b/docs/project-spec/walet-wireframe.md @@ -0,0 +1,428 @@ +# Wireframe Layar Aplikasi Sistem Inventory Walet + +## 1. Dashboard +### Tujuan +Memberikan gambaran cepat kondisi bisnis. + +### Komponen +- Total stok aktif +- Nilai inventory +- Pembelian bulan ini +- Penjualan bulan ini +- Susut bulan ini +- Top 5 supplier +- Top 5 customer +- Lot aging alert +- Lot hold alert +- Shortcut ke Purchase, Receiving, Sales, Inventory + +### Widget utama +- Stok per jenis-grade +- Margin per periode +- Grafik pembelian vs penjualan +- Susut per supplier + +--- + +## 2. Purchase List +### Tujuan +Melihat semua transaksi pembelian. + +Catatan master terkait: +- data supplier perlu menyimpan nama bank dan nomor rekening untuk kebutuhan pembayaran + +### Komponen +- Filter tanggal +- Filter supplier +- Filter status +- Tombol buat pembelian baru +- Tabel: + - nomor pembelian + - supplier + - tanggal + - total + - status + - jumlah line + - aksi detail/edit + +--- + +## 3. Purchase Form +### Tujuan +Membuat transaksi pembelian multi jenis dan multi grade. + +### Header +- Purchase no +- Supplier +- Tanggal pembelian +- Invoice supplier +- Catatan + +### Detail line items +Kolom: +- Jenis +- Grade +- Qty +- Satuan +- Harga beli +- Subtotal +- Status klasifikasi +- Catatan + +### Aksi +- Tambah baris +- Simpan draft +- Submit +- Print + +--- + +## 4. Receipt Form +### Tujuan +Menerima barang dari pembelian dan membuat lot. + +### Header +- Receipt no +- Referensi purchase +- Supplier +- Tanggal terima +- Petugas penerima + +### Tabel line receiving +- Item purchase +- Qty ordered +- Qty received +- Qty accepted +- Qty rejected +- Unit cost +- Status +- Catatan + +### Aksi +- Generate lot +- Print QR label +- Simpan receipt + +--- + +## 5. Receipt Detail +### Tujuan +Melihat hasil penerimaan dan lot yang terbentuk. + +### Komponen +- Ringkasan receipt +- Daftar lot hasil receipt +- Kode lot +- Jenis +- Grade +- Qty +- Gudang +- Lokasi +- QR status +- Tombol print ulang label + +--- + +## 6. Stock Summary +### Tujuan +Melihat stok ringkas per jenis-grade. + +### Komponen +- Filter gudang +- Filter jenis +- Filter grade +- Tabel: + - jenis + - grade + - qty total + - nilai total + - jumlah lot aktif + +--- + +## 7. Stock Lot List +### Tujuan +Melihat stok detail berbasis lot. + +### Tabel +- Lot code +- Supplier +- Jenis +- Grade +- Qty awal +- Qty tersedia +- Unit cost +- Tanggal masuk +- Aging +- Status +- Gudang +- Lokasi +- Aksi detail + +### Filter +- supplier +- jenis +- grade +- status +- gudang +- aging + +--- + +## 8. Lot Detail +### Tujuan +Menjadi pusat traceability satu lot. + +### Section +#### Informasi utama +- Lot code +- Supplier +- Jenis +- Grade +- Qty awal +- Qty available +- Unit cost +- Gudang +- Lokasi +- Status + +#### Parent-child relation +- Parent lot +- Child lots hasil sortasi/regrade + +#### Movement history +- receiving +- sorting +- adjustment +- transfer +- sales allocation +- return + +#### Sales usage +- invoice mana saja yang memakai lot ini +- qty per invoice + +#### Aksi +- print ulang label +- hold lot +- release lot +- pindah lokasi +- adjustment +- regrade + +--- + +## 9. Sorting Session Form +### Tujuan +Memecah satu lot menjadi beberapa lot hasil sortasi. + +### Input utama +- Source lot +- Qty input +- Tanggal sortasi +- Operator +- Catatan + +### Hasil sortasi table +- Jenis hasil +- Grade hasil +- Qty hasil +- Cost +- Catatan + +### Additional +- Qty shrinkage +- Qty reject + +### Aksi +- Generate child lots +- Simpan sesi + +--- + +## 10. Regrade Form +### Tujuan +Memindahkan sebagian qty dari satu grade ke grade lain. + +### Komponen +- Source lot +- Grade asal +- Grade tujuan +- Qty pindah +- Alasan +- Catatan + +### Output +- lot lama berkurang +- lot baru terbentuk atau ditambah + +--- + +## 11. Sales Form +### Tujuan +Membuat transaksi penjualan. + +Catatan master terkait: +- data customer perlu menyimpan nama bank dan nomor rekening untuk kebutuhan pembayaran/settlement + +### Header +- Sales no +- Customer +- Tanggal +- Catatan + +### Detail lines +- Jenis +- Grade +- Qty +- Satuan +- Harga jual +- Subtotal + +### Aksi +- Simpan draft +- Lanjut ke allocation +- Submit + +--- + +## 12. Allocation Screen +### Tujuan +Mengalokasikan kebutuhan penjualan ke satu atau banyak lot. + +### Layout +#### Kiri +- informasi sales line +- jenis +- grade +- qty dibutuhkan + +#### Kanan +- daftar lot tersedia + - lot code + - supplier + - qty available + - unit cost + - tanggal masuk + - rekomendasi FIFO + +### Interaksi +- auto allocate +- manual select qty per lot +- validasi total allocation = qty line +- tampil total costing hasil allocation + +--- + +## 13. Picking Screen +### Tujuan +Memvalidasi pengambilan barang nyata dari lot teralokasi. + +### Komponen +- scan QR lot +- tampil info lot +- qty dialokasikan +- qty diambil aktual +- selisih jika ada +- konfirmasi picking selesai + +--- + +## 14. Transfer Form +### Tujuan +Memindahkan lot antar gudang atau lokasi. + +### Komponen +- scan/pilih lot +- gudang asal +- lokasi asal +- gudang tujuan +- lokasi tujuan +- qty pindah +- catatan + +--- + +## 15. Stock Adjustment Form +### Tujuan +Mencatat perubahan stok selain transaksi normal. + +### Komponen +- lot +- qty before +- qty change +- qty after +- jenis adjustment +- alasan +- cost impact +- catatan + +--- + +## 16. Barcode / QR Lookup +### Tujuan +Lookup cepat dari hasil scan. + +### Hasil scan menampilkan +- lot code +- supplier +- jenis +- grade +- qty available +- cost +- lokasi +- status +- histori singkat + +### Aksi cepat +- buka detail lot +- print label ulang +- pindah lokasi +- hold/release + +--- + +## 17. Reports +### Jenis laporan +- Pembelian +- Penerimaan +- Stok summary +- Stok lot +- Aging +- Sortasi +- Sales +- Margin +- Shrinkage +- Supplier quality +- Traceability + +### Filter umum +- tanggal +- supplier +- customer +- jenis +- grade +- gudang +- status + +--- + +## 18. Design Notes +- halaman operasional harus mobile-friendly +- allocation screen harus cepat, jangan terlalu penuh +- lot detail harus jadi layar kunci karena pusat trace +- scan workflow harus seminim mungkin input manual +- warna status penting, misal active, hold, closed, rejected + +## 19. Prioritas Wireframe MVP +Urutan yang sebaiknya didesain dulu: +1. Dashboard +2. Purchase Form +3. Receipt Form +4. Stock Lot List +5. Lot Detail +6. Sales Form +7. Allocation Screen +8. Picking Screen +9. Sorting Session Form +10. Barcode Lookup \ No newline at end of file diff --git a/docs/project-spec/walet-wireframe.pdf b/docs/project-spec/walet-wireframe.pdf new file mode 100644 index 0000000..ead2496 Binary files /dev/null and b/docs/project-spec/walet-wireframe.pdf differ diff --git a/docs/purchase-realization-design.md b/docs/purchase-realization-design.md new file mode 100644 index 0000000..0eb654c --- /dev/null +++ b/docs/purchase-realization-design.md @@ -0,0 +1,217 @@ +# Purchase Realization Design + +## Tujuan + +Menyediakan model data dan alur kalkulasi untuk melacak satu purchase dari: + +- pembelian awal +- receipt menjadi lot +- washing +- regrade / mix / split +- regular sale +- consignment +- office buyout + +sampai status akhir purchase dapat dinyatakan `CLOSED` dan laba/rugi agent dapat dihitung final. + +## Prinsip + +- `Purchase Analysis` tetap dipakai sebagai snapshot awal pembelian. +- `Purchase Realization` dipakai sebagai hasil aktual setelah barang bergerak dan terjual. +- Setiap lot harus bisa dilacak asal purchase-nya, termasuk jika hasil mix atau regrade. +- Setiap event yang memengaruhi qty atau nilai harus menulis jurnal realization. + +## Model Baru + +### `lot_purchase_allocations` + +Menyimpan komposisi asal purchase untuk setiap lot. + +Contoh: + +- Lot `LOT-A` berasal 100% dari purchase `P-1` +- Lot `LOT-B` hasil mix dari `P-1` 40% dan `P-2` 60% + +Maka `LOT-B` memiliki 2 allocation rows. + +Kolom penting: + +- `lot_id` +- `purchase_id` +- `purchase_line_id` +- `source_type` +- `source_ref_id` +- `qty_allocated` +- `cost_total_allocated` +- `unit_cost_snapshot` +- `agent_id_snapshot` +- `profit_share_scheme_id_snapshot` + +### `purchase_realization_entries` + +Ledger event yang memengaruhi hasil akhir purchase. + +Kolom penting: + +- `purchase_id` +- `lot_id` +- `allocation_id` +- `event_type` +- `reference_type` +- `reference_id` +- `occurred_at` +- `qty_in` +- `qty_out` +- `qty_shrinkage` +- `amount_cost` +- `amount_revenue` +- `amount_expense` +- `amount_profit` +- `agent_share_percent_snapshot` +- `agent_amount` + +Event type awal yang direkomendasikan: + +- `OPENING_COST` +- `WASHING_COST` +- `WASHING_SHRINKAGE` +- `TRANSFORMATION_SHRINKAGE` +- `SALE_REVENUE` +- `SALE_RETURN` +- `SALE_SHRINKAGE` +- `CONSIGNMENT_REVENUE` +- `CONSIGNMENT_RETURN` +- `CONSIGNMENT_SHRINKAGE` +- `OFFICE_BUYOUT_REVENUE` +- `OFFICE_BUYOUT_TRANSFER_OUT` +- `STOCK_ADJUSTMENT_LOSS` +- `STOCK_ADJUSTMENT_GAIN` +- `MANUAL_ADJUSTMENT` + +### `purchase_realization_summaries` + +Cache summary per purchase untuk kebutuhan UI dan closing. + +Kolom penting: + +- `status` +- `qty_opening` +- `qty_remaining` +- `qty_sold` +- `qty_returned` +- `qty_shrinkage` +- `cost_opening_total` +- `cost_additional_total` +- `revenue_total` +- `profit_loss_total` +- `agent_share_percent` +- `agent_profit_total` +- `closed_at` + +Status yang direkomendasikan: + +- `OPEN` +- `PARTIAL` +- `READY_TO_CLOSE` +- `CLOSED` + +## Aturan Alokasi + +### Purchase submit + +- Buat lot seperti implementasi saat ini. +- Buat 1 allocation row per lot: + - `source_type = PURCHASE` + - `qty_allocated = lot.original_qty` + - `cost_total_allocated = qty * unit_cost` +- Buat 1 realization entry: + - `event_type = OPENING_COST` + +### Washing selesai + +- Allocation lot tidak berubah. +- Jika ada biaya cuci, tulis `WASHING_COST`. +- Jika ada susut, tulis `WASHING_SHRINKAGE`. +- Semua entry dibagi ke purchase asal berdasarkan allocation aktif lot tersebut. + +### Transformation / regrade / mix + +- Baca allocation dari semua source lots. +- Bentuk allocation baru pada output lots dengan distribusi proporsional terhadap qty / cost input. +- Jika ada processing loss, buat `TRANSFORMATION_SHRINKAGE`. +- Output lot mewarisi allocation campuran dari input. + +### Regular sale close + +- Baca allocation lot pada line yang dijual. +- Revenue aktual dibagi ke purchase asal sesuai allocation. +- Tulis: + - `SALE_REVENUE` + - `SALE_RETURN` + - `SALE_SHRINKAGE` + +### Consignment close + +- Perlakuan sama seperti regular sale, tetapi sumber event adalah consignment line. + +### Office buyout + +Untuk purchase lama: + +- Tulis `OFFICE_BUYOUT_REVENUE` +- `qty_out` sebesar qty yang dibeli kantor +- `amount_revenue = qty * buyout_unit_price` + +Untuk lot baru hasil buyout: + +- Buat allocation baru yang menunjuk purchase buyout / jalur kantor +- Setelah itu hasil jual berikutnya tidak lagi menjadi hak agent lama + +## Rumus Summary + +- `cost_opening_total` = total opening cost +- `cost_additional_total` = washing cost + biaya tambahan + adjustment +- `revenue_total` = sale + consignment + office buyout +- `profit_loss_total = revenue_total - cost_opening_total - cost_additional_total` +- `agent_profit_total = akumulasi agent_amount` atau fallback `profit_loss_total * share_agent / 100` + +## Rule Closing Purchase + +Purchase dapat `CLOSED` jika seluruh qty asal purchase sudah terselesaikan menjadi salah satu: + +- terjual +- susut +- dibuyout kantor +- habis masuk transformasi dan seluruh descendant lots-nya selesai + +Artinya closing harus melihat seluruh lineage lot, bukan hanya lot awal purchase. + +## Scope Implementasi Bertahap + +### Tahap 1 + +- Tambah schema baru +- Isi allocation + opening realization saat purchase submit +- Tambah summary recalculation dasar + +### Tahap 2 + +- Integrasi office buyout +- Integrasi washing cost dan shrinkage + +### Tahap 3 + +- Integrasi transformation / mix / regrade allocation propagation + +### Tahap 4 + +- Integrasi regular sale dan consignment revenue realization + +### Tahap 5 + +- UI `Purchase Realization` +- closing otomatis + +### Tahap 6 + +- JIT sale, jika nanti ingin ditautkan ke lineage purchase diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..2c97fd8 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { AUTH_COOKIE_NAME } from "@/lib/auth"; + +const publicPaths = ["/login", "/reset-password", "/verify-email"]; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + const hasSession = Boolean(request.cookies.get(AUTH_COOKIE_NAME)?.value); + const isPublic = publicPaths.some((path) => pathname === path); + const isStaticAsset = + pathname.startsWith("/api/") || + pathname.startsWith("/_next") || + pathname.startsWith("/favicon.ico") || + pathname.match(/\.(.*)$/); + + if (isStaticAsset) { + return NextResponse.next(); + } + + if (pathname === "/") { + const url = request.nextUrl.clone(); + url.pathname = hasSession ? "/dashboard" : "/login"; + return NextResponse.redirect(url); + } + + if (!hasSession && !isPublic) { + const url = request.nextUrl.clone(); + url.pathname = "/login"; + url.searchParams.set("next", pathname); + return NextResponse.redirect(url); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] +}; diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..5bd0cc0 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,23 @@ +import type { NextConfig } from "next"; + +const securityHeaders = [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" } +]; + +const nextConfig: NextConfig = { + reactStrictMode: true, + poweredByHeader: false, + async headers() { + return [ + { + source: "/:path*", + headers: securityHeaders + } + ]; + } +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8cfc8c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3046 @@ +{ + "name": "abelbirdnest-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "abelbirdnest-web", + "version": "0.1.0", + "dependencies": { + "@prisma/client": "^6.17.1", + "@types/nodemailer": "^8.0.0", + "clsx": "^2.1.1", + "jsbarcode": "^3.12.1", + "lucide-react": "^0.511.0", + "next": "^15.0.0", + "nodemailer": "^8.0.7", + "qrcode": "^1.5.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "xlsx": "^0.18.5", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "prisma": "^6.17.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz", + "integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz", + "integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz", + "integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz", + "integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz", + "integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz", + "integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz", + "integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz", + "integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz", + "integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jsbarcode": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/jsbarcode/-/jsbarcode-3.12.1.tgz", + "integrity": "sha512-QZQSqIknC2Rr/YOUyOkCBqsoiBAOTYK+7yNN3JsqfoUtJtkazxNw1dmPpxuv7VVvqW13kA3/mKiLq+s/e3o9hQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lucide-react": { + "version": "0.511.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", + "integrity": "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", + "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.15", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.15", + "@next/swc-darwin-x64": "15.5.15", + "@next/swc-linux-arm64-gnu": "15.5.15", + "@next/swc-linux-arm64-musl": "15.5.15", + "@next/swc-linux-x64-gnu": "15.5.15", + "@next/swc-linux-x64-musl": "15.5.15", + "@next/swc-win32-arm64-msvc": "15.5.15", + "@next/swc-win32-x64-msvc": "15.5.15", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nypm": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.1.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f6265dd --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "abelbirdnest-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "prisma:generate": "prisma generate", + "db:push": "prisma db push", + "prisma:migrate:deploy": "prisma migrate deploy", + "seed:grades": "node scripts/seed-grades-from-xls.mjs", + "seed:banks": "node scripts/seed-banks-indonesia.mjs", + "seed:currencies": "node scripts/seed-global-currencies.mjs", + "seed:master": "npm run seed:grades && npm run seed:banks && npm run seed:currencies" + }, + "dependencies": { + "@prisma/client": "^6.17.1", + "@types/nodemailer": "^8.0.0", + "clsx": "^2.1.1", + "jsbarcode": "^3.12.1", + "lucide-react": "^0.511.0", + "next": "^15.0.0", + "nodemailer": "^8.0.7", + "qrcode": "^1.5.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "xlsx": "^0.18.5", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "prisma": "^6.17.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/migrations/0001_init/migration.sql b/prisma/migrations/0001_init/migration.sql new file mode 100644 index 0000000..319af20 --- /dev/null +++ b/prisma/migrations/0001_init/migration.sql @@ -0,0 +1,1410 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "roles" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(50) NOT NULL, + "name" VARCHAR(100) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" BIGSERIAL NOT NULL, + "role_id" BIGINT NOT NULL, + "name" VARCHAR(150) NOT NULL, + "username" VARCHAR(50), + "email" VARCHAR(150), + "email_verified_at" TIMESTAMP(3), + "phone" VARCHAR(50), + "password_hash" VARCHAR(255), + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_trails" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT, + "action" VARCHAR(100) NOT NULL, + "entity_type" VARCHAR(100) NOT NULL, + "entity_id" VARCHAR(100), + "method" VARCHAR(10) NOT NULL, + "pathname" VARCHAR(255) NOT NULL, + "status_code" INTEGER NOT NULL, + "summary" VARCHAR(255), + "metadata" JSONB, + "occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_trails_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "token_hash" VARCHAR(255) NOT NULL, + "purpose" VARCHAR(50) NOT NULL DEFAULT 'RESET_PASSWORD', + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "email_verification_tokens" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "token_hash" VARCHAR(255) NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "buyers" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(50) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "phone" VARCHAR(50), + "email" VARCHAR(150), + "bank_name" VARCHAR(100), + "bank_account_number" VARCHAR(100), + "address" TEXT, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "buyers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "buyer_contact_people" ( + "id" BIGSERIAL NOT NULL, + "buyer_id" BIGINT NOT NULL, + "name" VARCHAR(150) NOT NULL, + "mobile_phone" VARCHAR(50), + "email" VARCHAR(150), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "buyer_contact_people_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "employees" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "email" VARCHAR(150), + "mobile" VARCHAR(50), + "position" VARCHAR(150) NOT NULL, + "address" TEXT, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "employees_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "couriers" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "address" TEXT, + "phone" VARCHAR(50), + "website" VARCHAR(255), + "email" VARCHAR(150), + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "couriers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "washing_places" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "address" TEXT, + "phone" VARCHAR(50), + "website" VARCHAR(255), + "email" VARCHAR(150), + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "washing_places_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "washing_place_contact_people" ( + "id" BIGSERIAL NOT NULL, + "washing_place_id" BIGINT NOT NULL, + "name" VARCHAR(150) NOT NULL, + "mobile_phone" VARCHAR(50), + "email" VARCHAR(150), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "washing_place_contact_people_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "grades" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "legacy_code" VARCHAR(20), + "is_mangkok" BOOLEAN NOT NULL DEFAULT false, + "name" VARCHAR(150) NOT NULL, + "description" TEXT, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "grades_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "grade_buy_price_standards" ( + "id" BIGSERIAL NOT NULL, + "grade_id" BIGINT NOT NULL, + "start_date" DATE NOT NULL, + "end_date" DATE, + "min_price" DECIMAL(18,2) NOT NULL, + "max_price" DECIMAL(18,2) NOT NULL, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "grade_buy_price_standards_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "grade_sell_price_standards" ( + "id" BIGSERIAL NOT NULL, + "grade_id" BIGINT NOT NULL, + "start_date" DATE NOT NULL, + "end_date" DATE, + "min_price" DECIMAL(18,2) NOT NULL, + "max_price" DECIMAL(18,2) NOT NULL, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "grade_sell_price_standards_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "warehouses" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(50) NOT NULL, + "name" VARCHAR(100) NOT NULL, + "address" TEXT, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "warehouses_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "units" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "name" VARCHAR(50) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "units_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "currencies" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(10) NOT NULL, + "name" VARCHAR(100) NOT NULL, + "description" TEXT, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "currencies_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "banks" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(3) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "address" TEXT, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "banks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "profit_share_schemes" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "share_agent" DECIMAL(5,2) NOT NULL, + "share_company" DECIMAL(5,2) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "profit_share_schemes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agents" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "identity_type" VARCHAR(20) NOT NULL, + "identity_number" VARCHAR(100) NOT NULL, + "mobile_phone" VARCHAR(50), + "email" VARCHAR(150), + "address" TEXT, + "notes" TEXT, + "join_date" DATE NOT NULL, + "profit_share_scheme_id" BIGINT NOT NULL, + "current_balance" DECIMAL(18,2) NOT NULL DEFAULT 0, + "capital_balance" DECIMAL(18,2) NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "agents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agent_bank_accounts" ( + "id" BIGSERIAL NOT NULL, + "agent_id" BIGINT NOT NULL, + "bank_id" BIGINT NOT NULL, + "account_number" VARCHAR(100) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "agent_bank_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agent_balance_mutations" ( + "id" BIGSERIAL NOT NULL, + "agent_id" BIGINT NOT NULL, + "balance_type" VARCHAR(30) NOT NULL, + "direction" VARCHAR(10) NOT NULL, + "source" VARCHAR(50) NOT NULL, + "amount" DECIMAL(18,2) NOT NULL, + "balance_after" DECIMAL(18,2) NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "effective_date" DATE, + "reference_type" VARCHAR(50), + "reference_id" VARCHAR(100), + "reference_no" VARCHAR(100), + "notes" TEXT, + "metadata" JSONB, + + CONSTRAINT "agent_balance_mutations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "fund_requests" ( + "id" BIGSERIAL NOT NULL, + "request_no" VARCHAR(50) NOT NULL, + "reference_no" VARCHAR(100) NOT NULL, + "transfer_type" VARCHAR(30) NOT NULL, + "agent_id" BIGINT NOT NULL, + "agent_bank_account_id" BIGINT NOT NULL, + "company_bank_account_id" BIGINT, + "agent_bank_name_snapshot" VARCHAR(150) NOT NULL, + "agent_account_number_snapshot" VARCHAR(100) NOT NULL, + "company_bank_name_snapshot" VARCHAR(150) NOT NULL, + "company_account_number_snapshot" VARCHAR(100) NOT NULL, + "amount" DECIMAL(18,2) NOT NULL, + "currency_code" VARCHAR(10) NOT NULL DEFAULT 'IDR', + "transferred_at" TIMESTAMP(3) NOT NULL, + "transfer_proof_file_url" VARCHAR(255), + "status" VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED', + "created_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "fund_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sales" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(20) NOT NULL, + "name" VARCHAR(150) NOT NULL, + "identity_type" VARCHAR(20) NOT NULL, + "identity_number" VARCHAR(100) NOT NULL, + "mobile_phone" VARCHAR(50), + "email" VARCHAR(150), + "address" TEXT, + "notes" TEXT, + "join_date" DATE NOT NULL, + "commission_balance" DECIMAL(18,2) NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sales_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sales_commission_mutations" ( + "id" BIGSERIAL NOT NULL, + "sales_id" BIGINT NOT NULL, + "source" VARCHAR(50) NOT NULL, + "direction" VARCHAR(10) NOT NULL, + "amount" DECIMAL(18,2) NOT NULL, + "balance_after" DECIMAL(18,2) NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "effective_date" DATE, + "reference_type" VARCHAR(50), + "reference_id" VARCHAR(100), + "reference_no" VARCHAR(100), + "notes" TEXT, + "metadata" JSONB, + + CONSTRAINT "sales_commission_mutations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sales_bank_accounts" ( + "id" BIGSERIAL NOT NULL, + "sales_id" BIGINT NOT NULL, + "bank_id" BIGINT NOT NULL, + "account_number" VARCHAR(100) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sales_bank_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "warehouse_locations" ( + "id" BIGSERIAL NOT NULL, + "warehouse_id" BIGINT NOT NULL, + "code" VARCHAR(50) NOT NULL, + "name" VARCHAR(100) NOT NULL, + "location_type" VARCHAR(50), + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "warehouse_locations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "adjustment_reasons" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(50) NOT NULL, + "name" VARCHAR(100) NOT NULL, + "category" VARCHAR(50) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "adjustment_reasons_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "purchases" ( + "id" BIGSERIAL NOT NULL, + "purchase_no" VARCHAR(50) NOT NULL, + "purchase_type" VARCHAR(30) NOT NULL DEFAULT 'REGULAR', + "purchase_date" DATE NOT NULL, + "supplier_invoice_no" VARCHAR(100), + "agent_id" BIGINT, + "buyout_source_agent_id" BIGINT, + "profit_share_scheme_id" BIGINT, + "courier_id" BIGINT, + "received_by_employee_id" BIGINT, + "received_at" TIMESTAMP(3), + "moisture_buy_percent" DECIMAL(7,2), + "moisture_received_percent" DECIMAL(7,2), + "above_average_ratio_percent" DECIMAL(7,2), + "mk_share_percent" DECIMAL(7,2), + "non_mk_share_percent" DECIMAL(7,2), + "shipping_cost" DECIMAL(18,2), + "incoming_operational_cost" DECIMAL(18,2), + "after_arrival_operational_cost" DECIMAL(18,2), + "status" VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + "notes" TEXT, + "created_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "purchases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "purchase_lines" ( + "id" BIGSERIAL NOT NULL, + "purchase_id" BIGINT NOT NULL, + "grade_id" BIGINT, + "source_lot_id" BIGINT, + "qty_ordered" DECIMAL(18,3) NOT NULL, + "purchase_moisture_percent" DECIMAL(7,2), + "qty_received" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_accepted" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_rejected" DECIMAL(18,3) NOT NULL, + "moisture_received_percent" DECIMAL(7,2), + "unit_id" BIGINT NOT NULL, + "unit_price" DECIMAL(18,2) NOT NULL, + "buyout_mal_unit_price_snapshot" DECIMAL(18,2), + "buyout_agent_share_percent" DECIMAL(5,2), + "buyout_profit_amount" DECIMAL(18,2), + "buyout_agent_commission" DECIMAL(18,2), + "market_reference_price" DECIMAL(18,2), + "unit_cost" DECIMAL(18,2) NOT NULL DEFAULT 0, + "mal_unit_price" DECIMAL(18,2), + "subtotal" DECIMAL(18,2) NOT NULL, + "classification_status" VARCHAR(20) NOT NULL DEFAULT 'FINAL', + "warehouse_id" BIGINT, + "warehouse_location_id" BIGINT, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "purchase_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "receipts" ( + "id" BIGSERIAL NOT NULL, + "receipt_no" VARCHAR(50) NOT NULL, + "purchase_id" BIGINT NOT NULL, + "receipt_date" DATE NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + "notes" TEXT, + "received_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "receipts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "receipt_lines" ( + "id" BIGSERIAL NOT NULL, + "receipt_id" BIGINT NOT NULL, + "purchase_line_id" BIGINT NOT NULL, + "grade_id" BIGINT, + "qty_received" DECIMAL(18,3) NOT NULL, + "qty_accepted" DECIMAL(18,3) NOT NULL, + "qty_rejected" DECIMAL(18,3) NOT NULL DEFAULT 0, + "moisture_percent" DECIMAL(7,2), + "unit_id" BIGINT NOT NULL, + "unit_cost" DECIMAL(18,2) NOT NULL, + "warehouse_id" BIGINT NOT NULL, + "warehouse_location_id" BIGINT, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "receipt_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "inventory_lots" ( + "id" BIGSERIAL NOT NULL, + "lot_code" VARCHAR(100) NOT NULL, + "parent_lot_id" BIGINT, + "source_type" VARCHAR(30) NOT NULL, + "source_ref_id" BIGINT, + "purchase_id" BIGINT, + "purchase_line_id" BIGINT, + "receipt_id" BIGINT, + "receipt_line_id" BIGINT, + "grade_id" BIGINT, + "warehouse_id" BIGINT NOT NULL, + "warehouse_location_id" BIGINT, + "original_qty" DECIMAL(18,3) NOT NULL, + "available_qty" DECIMAL(18,3) NOT NULL, + "reserved_qty" DECIMAL(18,3) NOT NULL DEFAULT 0, + "damaged_qty" DECIMAL(18,3) NOT NULL DEFAULT 0, + "shrinkage_qty" DECIMAL(18,3) NOT NULL DEFAULT 0, + "final_moisture_percent" DECIMAL(7,2), + "above_average_ratio_percent" DECIMAL(7,2), + "unit_id" BIGINT NOT NULL, + "unit_cost" DECIMAL(18,2) NOT NULL, + "received_at" TIMESTAMP(3) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "qr_code_value" VARCHAR(255), + "barcode_value" VARCHAR(255), + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "inventory_lots_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "consignments" ( + "id" BIGSERIAL NOT NULL, + "consignment_no" VARCHAR(50) NOT NULL, + "consignment_date" DATE NOT NULL, + "sales_id" BIGINT NOT NULL, + "buyer_id" BIGINT NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'OPEN', + "notes" TEXT, + "created_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "consignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "consignment_lines" ( + "id" BIGSERIAL NOT NULL, + "consignment_id" BIGINT NOT NULL, + "lot_id" BIGINT NOT NULL, + "qty_consigned" DECIMAL(18,3) NOT NULL, + "available_qty_snapshot" DECIMAL(18,3) NOT NULL, + "mal_unit_price_snapshot" DECIMAL(18,2), + "agent_name_snapshot" VARCHAR(150), + "agent_share_percent" DECIMAL(5,2), + "status" VARCHAR(20) NOT NULL DEFAULT 'OPEN', + "notes" TEXT, + "close_date" DATE, + "selling_price" DECIMAL(18,2), + "qty_sold" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_returned" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_shrinkage" DECIMAL(18,3) NOT NULL DEFAULT 0, + "sales_commission" DECIMAL(18,2) NOT NULL DEFAULT 0, + "agent_commission" DECIMAL(18,2) NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "consignment_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "regular_sales" ( + "id" BIGSERIAL NOT NULL, + "sale_no" VARCHAR(50) NOT NULL, + "sale_date" DATE NOT NULL, + "buyer_id" BIGINT NOT NULL, + "buyer_currency_code" VARCHAR(10) NOT NULL, + "company_currency_code" VARCHAR(10) NOT NULL, + "exchange_rate" DECIMAL(18,6), + "courier_id" BIGINT, + "shipping_cost_buyer" DECIMAL(18,2) NOT NULL DEFAULT 0, + "shipping_cost_company" DECIMAL(18,2) NOT NULL DEFAULT 0, + "shipping_receipt_file_url" VARCHAR(255), + "close_date" DATE, + "total_nominal_buyer" DECIMAL(18,2) NOT NULL DEFAULT 0, + "total_nominal_company" DECIMAL(18,2) NOT NULL DEFAULT 0, + "total_agent_commission" DECIMAL(18,2) NOT NULL DEFAULT 0, + "status" VARCHAR(20) NOT NULL DEFAULT 'IN_PROGRESS', + "notes" TEXT, + "created_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "regular_sales_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "regular_sale_lines" ( + "id" BIGSERIAL NOT NULL, + "regular_sale_id" BIGINT NOT NULL, + "lot_id" BIGINT NOT NULL, + "available_qty_snapshot" DECIMAL(18,3) NOT NULL, + "mal_unit_price_snapshot" DECIMAL(18,2), + "agent_id" BIGINT, + "agent_name_snapshot" VARCHAR(150), + "agent_share_percent" DECIMAL(5,2), + "qty_planned" DECIMAL(18,3) NOT NULL, + "selling_price_planned" DECIMAL(18,2) NOT NULL, + "qty_actual_sold" DECIMAL(18,3), + "qty_returned" DECIMAL(18,3), + "qty_shrinkage" DECIMAL(18,3), + "selling_price_actual" DECIMAL(18,2), + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "regular_sale_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "jit_sales" ( + "id" BIGSERIAL NOT NULL, + "sale_no" VARCHAR(50) NOT NULL, + "sale_date" DATE NOT NULL, + "buyer_id" BIGINT NOT NULL, + "buyer_currency_code" VARCHAR(10) NOT NULL, + "company_currency_code" VARCHAR(10) NOT NULL, + "exchange_rate" DECIMAL(18,6), + "courier_id" BIGINT, + "shipping_cost_buyer" DECIMAL(18,2) NOT NULL DEFAULT 0, + "shipping_cost_company" DECIMAL(18,2) NOT NULL DEFAULT 0, + "shipping_receipt_file_url" VARCHAR(255), + "close_date" DATE, + "total_nominal_buyer" DECIMAL(18,2) NOT NULL DEFAULT 0, + "total_nominal_company" DECIMAL(18,2) NOT NULL DEFAULT 0, + "total_agent_commission" DECIMAL(18,2) NOT NULL DEFAULT 0, + "status" VARCHAR(20) NOT NULL DEFAULT 'OPEN', + "notes" TEXT, + "created_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "jit_sales_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "jit_sale_lines" ( + "id" BIGSERIAL NOT NULL, + "jit_sale_id" BIGINT NOT NULL, + "grade_id" BIGINT NOT NULL, + "qty_planned" DECIMAL(18,3) NOT NULL, + "qty_actual_sold" DECIMAL(18,3), + "mal_unit_price" DECIMAL(18,2) NOT NULL, + "selling_price_planned" DECIMAL(18,2) NOT NULL, + "selling_price_actual" DECIMAL(18,2), + "agent_id" BIGINT, + "agent_name_snapshot" VARCHAR(150), + "profit_share_scheme_id" BIGINT, + "profit_share_scheme_name" VARCHAR(150), + "agent_share_percent" DECIMAL(5,2), + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "jit_sale_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "stock_adjustments" ( + "id" BIGSERIAL NOT NULL, + "adjustment_no" VARCHAR(50) NOT NULL, + "lot_id" BIGINT NOT NULL, + "adjustment_reason_id" BIGINT NOT NULL, + "adjustment_date" DATE NOT NULL, + "qty_change" DECIMAL(18,3) NOT NULL, + "available_qty_before" DECIMAL(18,3) NOT NULL, + "available_qty_after" DECIMAL(18,3) NOT NULL, + "notes" TEXT, + "created_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "stock_adjustments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lot_transformations" ( + "id" BIGSERIAL NOT NULL, + "transformation_no" VARCHAR(50) NOT NULL, + "transformation_type" VARCHAR(30) NOT NULL DEFAULT 'MIX', + "transformation_date" DATE NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'FINALIZED', + "remainder_mode" VARCHAR(30), + "remainder_qty" DECIMAL(18,3), + "processing_loss_mode" VARCHAR(30), + "processing_loss_qty" DECIMAL(18,3), + "notes" TEXT, + "created_by" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "lot_transformations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lot_transformation_inputs" ( + "id" BIGSERIAL NOT NULL, + "transformation_id" BIGINT NOT NULL, + "source_lot_id" BIGINT NOT NULL, + "qty_used" DECIMAL(18,3) NOT NULL, + "unit_cost_snapshot" DECIMAL(18,2) NOT NULL, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "lot_transformation_inputs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lot_transformation_outputs" ( + "id" BIGSERIAL NOT NULL, + "transformation_id" BIGINT NOT NULL, + "result_lot_id" BIGINT NOT NULL, + "qty_produced" DECIMAL(18,3) NOT NULL, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "lot_transformation_outputs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "purchase_analyses" ( + "id" BIGSERIAL NOT NULL, + "purchase_id" BIGINT NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + "weight_buy" DECIMAL(18,3), + "weight_received" DECIMAL(18,3), + "weight_final" DECIMAL(18,3), + "moisture_buy_percent" DECIMAL(7,2), + "moisture_received_percent" DECIMAL(7,2), + "moisture_final_percent" DECIMAL(7,2), + "above_average_ratio_percent" DECIMAL(7,2), + "average_price" DECIMAL(18,2), + "modal_beli" DECIMAL(18,2), + "modal_masuk" DECIMAL(18,2), + "modal_jual" DECIMAL(18,2), + "modal_barang" DECIMAL(18,2), + "total_modal_beli" DECIMAL(18,2), + "total_modal_mal" DECIMAL(18,2), + "market_reference_price" DECIMAL(18,2), + "market_valuation_total" DECIMAL(18,2), + "agent_profit_share_total" DECIMAL(18,2) NOT NULL DEFAULT 0, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "purchase_analyses_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "purchase_analysis_cost_entries" ( + "id" BIGSERIAL NOT NULL, + "analysis_id" BIGINT NOT NULL, + "cost_type" VARCHAR(50) NOT NULL, + "description" VARCHAR(255), + "amount" DECIMAL(18,2) NOT NULL, + "proof_file_url" VARCHAR(255), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "purchase_analysis_cost_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lot_purchase_allocations" ( + "id" BIGSERIAL NOT NULL, + "lot_id" BIGINT NOT NULL, + "purchase_id" BIGINT NOT NULL, + "purchase_line_id" BIGINT, + "source_type" VARCHAR(30) NOT NULL, + "source_ref_id" BIGINT, + "agent_id_snapshot" BIGINT, + "profit_share_scheme_id_snapshot" BIGINT, + "qty_allocated" DECIMAL(18,3) NOT NULL, + "cost_total_allocated" DECIMAL(18,2) NOT NULL, + "unit_cost_snapshot" DECIMAL(18,2) NOT NULL, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "lot_purchase_allocations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "purchase_realization_entries" ( + "id" BIGSERIAL NOT NULL, + "purchase_id" BIGINT NOT NULL, + "lot_id" BIGINT, + "allocation_id" BIGINT, + "event_type" VARCHAR(40) NOT NULL, + "reference_type" VARCHAR(40) NOT NULL, + "reference_id" BIGINT, + "occurred_at" TIMESTAMP(3) NOT NULL, + "qty_in" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_out" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_shrinkage" DECIMAL(18,3) NOT NULL DEFAULT 0, + "amount_cost" DECIMAL(18,2) NOT NULL DEFAULT 0, + "amount_revenue" DECIMAL(18,2) NOT NULL DEFAULT 0, + "amount_expense" DECIMAL(18,2) NOT NULL DEFAULT 0, + "amount_profit" DECIMAL(18,2) NOT NULL DEFAULT 0, + "agent_share_percent_snapshot" DECIMAL(7,2), + "agent_amount" DECIMAL(18,2) NOT NULL DEFAULT 0, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "purchase_realization_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "purchase_realization_summaries" ( + "id" BIGSERIAL NOT NULL, + "purchase_id" BIGINT NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'OPEN', + "qty_opening" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_remaining" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_sold" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_returned" DECIMAL(18,3) NOT NULL DEFAULT 0, + "qty_shrinkage" DECIMAL(18,3) NOT NULL DEFAULT 0, + "cost_opening_total" DECIMAL(18,2) NOT NULL DEFAULT 0, + "cost_additional_total" DECIMAL(18,2) NOT NULL DEFAULT 0, + "revenue_total" DECIMAL(18,2) NOT NULL DEFAULT 0, + "profit_loss_total" DECIMAL(18,2) NOT NULL DEFAULT 0, + "agent_share_percent" DECIMAL(7,2), + "agent_profit_total" DECIMAL(18,2) NOT NULL DEFAULT 0, + "closed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "purchase_realization_summaries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lot_washings" ( + "id" BIGSERIAL NOT NULL, + "washing_no" VARCHAR(50) NOT NULL, + "lot_id" BIGINT NOT NULL, + "washing_place_id" BIGINT NOT NULL, + "washing_cost" DECIMAL(18,2) NOT NULL, + "duration_hours" INTEGER NOT NULL, + "receipt_file_url" VARCHAR(255), + "status" VARCHAR(20) NOT NULL DEFAULT 'IN_PROGRESS', + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expected_done_at" TIMESTAMP(3) NOT NULL, + "completed_at" TIMESTAMP(3), + "before_qty" DECIMAL(18,3) NOT NULL, + "after_qty" DECIMAL(18,3), + "shrinkage_qty" DECIMAL(18,3), + "before_grade_name" VARCHAR(100), + "after_grade_name" VARCHAR(100), + "before_warehouse_name" VARCHAR(150) NOT NULL, + "before_location_name" VARCHAR(150), + "after_warehouse_name" VARCHAR(150), + "after_location_name" VARCHAR(150), + "created_by" BIGINT NOT NULL, + "completed_by" BIGINT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "lot_washings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "app_settings" ( + "id" BIGSERIAL NOT NULL, + "singleton_key" VARCHAR(30) NOT NULL DEFAULT 'SYSTEM', + "company_name" VARCHAR(150), + "company_email" VARCHAR(150), + "company_phone" VARCHAR(50), + "company_bank_name" VARCHAR(150), + "company_bank_account_number" VARCHAR(80), + "company_address" TEXT, + "company_timezone" VARCHAR(80) NOT NULL DEFAULT 'Asia/Jakarta', + "smtp_host" VARCHAR(150), + "smtp_port" INTEGER, + "smtp_secure" BOOLEAN NOT NULL DEFAULT true, + "smtp_user" VARCHAR(150), + "smtp_password" VARCHAR(255), + "smtp_from_name" VARCHAR(150), + "smtp_from_email" VARCHAR(150), + "purchase_prefix" VARCHAR(20) NOT NULL DEFAULT 'PO', + "receipt_prefix" VARCHAR(20) NOT NULL DEFAULT 'RCV', + "lot_prefix" VARCHAR(20) NOT NULL DEFAULT 'LOT', + "adjustment_prefix" VARCHAR(20) NOT NULL DEFAULT 'STA', + "transformation_prefix" VARCHAR(20) NOT NULL DEFAULT 'MIX', + "fund_request_prefix" VARCHAR(20) NOT NULL DEFAULT 'FR', + "washing_prefix" VARCHAR(20) NOT NULL DEFAULT 'WSH', + "regular_sale_prefix" VARCHAR(20) NOT NULL DEFAULT 'SRG', + "jit_sale_prefix" VARCHAR(20) NOT NULL DEFAULT 'SJT', + "consignment_prefix" VARCHAR(20) NOT NULL DEFAULT 'TJT', + "default_locale" VARCHAR(10) NOT NULL DEFAULT 'id', + "currency_code" VARCHAR(10) NOT NULL DEFAULT 'IDR', + "date_format" VARCHAR(30) NOT NULL DEFAULT 'DD/MM/YYYY', + "password_min_length" INTEGER NOT NULL DEFAULT 8, + "session_timeout_minutes" INTEGER NOT NULL DEFAULT 720, + "require_email_verification" BOOLEAN NOT NULL DEFAULT true, + "audit_retention_days" INTEGER NOT NULL DEFAULT 365, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "company_bank_accounts" ( + "id" BIGSERIAL NOT NULL, + "app_setting_id" BIGINT NOT NULL, + "bank_id" BIGINT NOT NULL, + "account_number" VARCHAR(100) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "company_bank_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "audit_trails_action_occurred_at_idx" ON "audit_trails"("action", "occurred_at"); + +-- CreateIndex +CREATE INDEX "audit_trails_entity_type_occurred_at_idx" ON "audit_trails"("entity_type", "occurred_at"); + +-- CreateIndex +CREATE INDEX "audit_trails_user_id_occurred_at_idx" ON "audit_trails"("user_id", "occurred_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key" ON "password_reset_tokens"("token_hash"); + +-- CreateIndex +CREATE UNIQUE INDEX "email_verification_tokens_token_hash_key" ON "email_verification_tokens"("token_hash"); + +-- CreateIndex +CREATE UNIQUE INDEX "buyers_code_key" ON "buyers"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "employees_code_key" ON "employees"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "couriers_code_key" ON "couriers"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "washing_places_code_key" ON "washing_places"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "grades_code_key" ON "grades"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "grades_legacy_code_key" ON "grades"("legacy_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "warehouses_code_key" ON "warehouses"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "units_code_key" ON "units"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "currencies_code_key" ON "currencies"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "banks_code_key" ON "banks"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "banks_name_key" ON "banks"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "profit_share_schemes_code_key" ON "profit_share_schemes"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "agents_code_key" ON "agents"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "agents_identity_type_identity_number_key" ON "agents"("identity_type", "identity_number"); + +-- CreateIndex +CREATE UNIQUE INDEX "agent_bank_accounts_agent_id_bank_id_account_number_key" ON "agent_bank_accounts"("agent_id", "bank_id", "account_number"); + +-- CreateIndex +CREATE INDEX "agent_balance_mutations_agent_id_occurred_at_idx" ON "agent_balance_mutations"("agent_id", "occurred_at"); + +-- CreateIndex +CREATE INDEX "agent_balance_mutations_agent_id_balance_type_occurred_at_idx" ON "agent_balance_mutations"("agent_id", "balance_type", "occurred_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "fund_requests_request_no_key" ON "fund_requests"("request_no"); + +-- CreateIndex +CREATE INDEX "fund_requests_agent_id_transferred_at_idx" ON "fund_requests"("agent_id", "transferred_at"); + +-- CreateIndex +CREATE INDEX "fund_requests_transfer_type_transferred_at_idx" ON "fund_requests"("transfer_type", "transferred_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "sales_code_key" ON "sales"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "sales_identity_type_identity_number_key" ON "sales"("identity_type", "identity_number"); + +-- CreateIndex +CREATE INDEX "sales_commission_mutations_sales_id_occurred_at_idx" ON "sales_commission_mutations"("sales_id", "occurred_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "sales_bank_accounts_sales_id_bank_id_account_number_key" ON "sales_bank_accounts"("sales_id", "bank_id", "account_number"); + +-- CreateIndex +CREATE UNIQUE INDEX "warehouse_locations_warehouse_id_code_key" ON "warehouse_locations"("warehouse_id", "code"); + +-- CreateIndex +CREATE UNIQUE INDEX "adjustment_reasons_code_key" ON "adjustment_reasons"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "purchases_purchase_no_key" ON "purchases"("purchase_no"); + +-- CreateIndex +CREATE UNIQUE INDEX "receipts_receipt_no_key" ON "receipts"("receipt_no"); + +-- CreateIndex +CREATE UNIQUE INDEX "inventory_lots_lot_code_key" ON "inventory_lots"("lot_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "consignments_consignment_no_key" ON "consignments"("consignment_no"); + +-- CreateIndex +CREATE UNIQUE INDEX "regular_sales_sale_no_key" ON "regular_sales"("sale_no"); + +-- CreateIndex +CREATE UNIQUE INDEX "regular_sale_lines_regular_sale_id_lot_id_key" ON "regular_sale_lines"("regular_sale_id", "lot_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "jit_sales_sale_no_key" ON "jit_sales"("sale_no"); + +-- CreateIndex +CREATE UNIQUE INDEX "stock_adjustments_adjustment_no_key" ON "stock_adjustments"("adjustment_no"); + +-- CreateIndex +CREATE UNIQUE INDEX "lot_transformations_transformation_no_key" ON "lot_transformations"("transformation_no"); + +-- CreateIndex +CREATE UNIQUE INDEX "lot_transformation_inputs_transformation_id_source_lot_id_key" ON "lot_transformation_inputs"("transformation_id", "source_lot_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "lot_transformation_outputs_result_lot_id_key" ON "lot_transformation_outputs"("result_lot_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "purchase_analyses_purchase_id_key" ON "purchase_analyses"("purchase_id"); + +-- CreateIndex +CREATE INDEX "lot_purchase_allocations_lot_id_idx" ON "lot_purchase_allocations"("lot_id"); + +-- CreateIndex +CREATE INDEX "lot_purchase_allocations_purchase_id_idx" ON "lot_purchase_allocations"("purchase_id"); + +-- CreateIndex +CREATE INDEX "lot_purchase_allocations_purchase_line_id_idx" ON "lot_purchase_allocations"("purchase_line_id"); + +-- CreateIndex +CREATE INDEX "lot_purchase_allocations_source_type_source_ref_id_idx" ON "lot_purchase_allocations"("source_type", "source_ref_id"); + +-- CreateIndex +CREATE INDEX "purchase_realization_entries_purchase_id_occurred_at_idx" ON "purchase_realization_entries"("purchase_id", "occurred_at"); + +-- CreateIndex +CREATE INDEX "purchase_realization_entries_lot_id_idx" ON "purchase_realization_entries"("lot_id"); + +-- CreateIndex +CREATE INDEX "purchase_realization_entries_allocation_id_idx" ON "purchase_realization_entries"("allocation_id"); + +-- CreateIndex +CREATE INDEX "purchase_realization_entries_reference_type_reference_id_idx" ON "purchase_realization_entries"("reference_type", "reference_id"); + +-- CreateIndex +CREATE INDEX "purchase_realization_entries_event_type_occurred_at_idx" ON "purchase_realization_entries"("event_type", "occurred_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "purchase_realization_summaries_purchase_id_key" ON "purchase_realization_summaries"("purchase_id"); + +-- CreateIndex +CREATE INDEX "purchase_realization_summaries_status_idx" ON "purchase_realization_summaries"("status"); + +-- CreateIndex +CREATE INDEX "purchase_realization_summaries_closed_at_idx" ON "purchase_realization_summaries"("closed_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "lot_washings_washing_no_key" ON "lot_washings"("washing_no"); + +-- CreateIndex +CREATE INDEX "lot_washings_lot_id_status_idx" ON "lot_washings"("lot_id", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "app_settings_singleton_key_key" ON "app_settings"("singleton_key"); + +-- CreateIndex +CREATE UNIQUE INDEX "company_bank_accounts_app_setting_id_bank_id_account_number_key" ON "company_bank_accounts"("app_setting_id", "bank_id", "account_number"); + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_trails" ADD CONSTRAINT "audit_trails_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "buyer_contact_people" ADD CONSTRAINT "buyer_contact_people_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "buyers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "washing_place_contact_people" ADD CONSTRAINT "washing_place_contact_people_washing_place_id_fkey" FOREIGN KEY ("washing_place_id") REFERENCES "washing_places"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "grade_buy_price_standards" ADD CONSTRAINT "grade_buy_price_standards_grade_id_fkey" FOREIGN KEY ("grade_id") REFERENCES "grades"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "grade_sell_price_standards" ADD CONSTRAINT "grade_sell_price_standards_grade_id_fkey" FOREIGN KEY ("grade_id") REFERENCES "grades"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agents" ADD CONSTRAINT "agents_profit_share_scheme_id_fkey" FOREIGN KEY ("profit_share_scheme_id") REFERENCES "profit_share_schemes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_bank_accounts" ADD CONSTRAINT "agent_bank_accounts_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_bank_accounts" ADD CONSTRAINT "agent_bank_accounts_bank_id_fkey" FOREIGN KEY ("bank_id") REFERENCES "banks"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_balance_mutations" ADD CONSTRAINT "agent_balance_mutations_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fund_requests" ADD CONSTRAINT "fund_requests_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fund_requests" ADD CONSTRAINT "fund_requests_agent_bank_account_id_fkey" FOREIGN KEY ("agent_bank_account_id") REFERENCES "agent_bank_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fund_requests" ADD CONSTRAINT "fund_requests_company_bank_account_id_fkey" FOREIGN KEY ("company_bank_account_id") REFERENCES "company_bank_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "fund_requests" ADD CONSTRAINT "fund_requests_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sales_commission_mutations" ADD CONSTRAINT "sales_commission_mutations_sales_id_fkey" FOREIGN KEY ("sales_id") REFERENCES "sales"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sales_bank_accounts" ADD CONSTRAINT "sales_bank_accounts_sales_id_fkey" FOREIGN KEY ("sales_id") REFERENCES "sales"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sales_bank_accounts" ADD CONSTRAINT "sales_bank_accounts_bank_id_fkey" FOREIGN KEY ("bank_id") REFERENCES "banks"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "warehouse_locations" ADD CONSTRAINT "warehouse_locations_warehouse_id_fkey" FOREIGN KEY ("warehouse_id") REFERENCES "warehouses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchases" ADD CONSTRAINT "purchases_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchases" ADD CONSTRAINT "purchases_buyout_source_agent_id_fkey" FOREIGN KEY ("buyout_source_agent_id") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchases" ADD CONSTRAINT "purchases_profit_share_scheme_id_fkey" FOREIGN KEY ("profit_share_scheme_id") REFERENCES "profit_share_schemes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchases" ADD CONSTRAINT "purchases_courier_id_fkey" FOREIGN KEY ("courier_id") REFERENCES "couriers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchases" ADD CONSTRAINT "purchases_received_by_employee_id_fkey" FOREIGN KEY ("received_by_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchases" ADD CONSTRAINT "purchases_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_lines" ADD CONSTRAINT "purchase_lines_purchase_id_fkey" FOREIGN KEY ("purchase_id") REFERENCES "purchases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_lines" ADD CONSTRAINT "purchase_lines_grade_id_fkey" FOREIGN KEY ("grade_id") REFERENCES "grades"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_lines" ADD CONSTRAINT "purchase_lines_source_lot_id_fkey" FOREIGN KEY ("source_lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_lines" ADD CONSTRAINT "purchase_lines_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "units"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_lines" ADD CONSTRAINT "purchase_lines_warehouse_id_fkey" FOREIGN KEY ("warehouse_id") REFERENCES "warehouses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_lines" ADD CONSTRAINT "purchase_lines_warehouse_location_id_fkey" FOREIGN KEY ("warehouse_location_id") REFERENCES "warehouse_locations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_purchase_id_fkey" FOREIGN KEY ("purchase_id") REFERENCES "purchases"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipts" ADD CONSTRAINT "receipts_received_by_fkey" FOREIGN KEY ("received_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipt_lines" ADD CONSTRAINT "receipt_lines_receipt_id_fkey" FOREIGN KEY ("receipt_id") REFERENCES "receipts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipt_lines" ADD CONSTRAINT "receipt_lines_purchase_line_id_fkey" FOREIGN KEY ("purchase_line_id") REFERENCES "purchase_lines"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipt_lines" ADD CONSTRAINT "receipt_lines_grade_id_fkey" FOREIGN KEY ("grade_id") REFERENCES "grades"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipt_lines" ADD CONSTRAINT "receipt_lines_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "units"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipt_lines" ADD CONSTRAINT "receipt_lines_warehouse_id_fkey" FOREIGN KEY ("warehouse_id") REFERENCES "warehouses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "receipt_lines" ADD CONSTRAINT "receipt_lines_warehouse_location_id_fkey" FOREIGN KEY ("warehouse_location_id") REFERENCES "warehouse_locations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_parent_lot_id_fkey" FOREIGN KEY ("parent_lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_purchase_id_fkey" FOREIGN KEY ("purchase_id") REFERENCES "purchases"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_purchase_line_id_fkey" FOREIGN KEY ("purchase_line_id") REFERENCES "purchase_lines"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_receipt_id_fkey" FOREIGN KEY ("receipt_id") REFERENCES "receipts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_receipt_line_id_fkey" FOREIGN KEY ("receipt_line_id") REFERENCES "receipt_lines"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_grade_id_fkey" FOREIGN KEY ("grade_id") REFERENCES "grades"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_warehouse_id_fkey" FOREIGN KEY ("warehouse_id") REFERENCES "warehouses"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_warehouse_location_id_fkey" FOREIGN KEY ("warehouse_location_id") REFERENCES "warehouse_locations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_lots" ADD CONSTRAINT "inventory_lots_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "units"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "consignments" ADD CONSTRAINT "consignments_sales_id_fkey" FOREIGN KEY ("sales_id") REFERENCES "sales"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "consignments" ADD CONSTRAINT "consignments_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "buyers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "consignments" ADD CONSTRAINT "consignments_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "consignment_lines" ADD CONSTRAINT "consignment_lines_consignment_id_fkey" FOREIGN KEY ("consignment_id") REFERENCES "consignments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "consignment_lines" ADD CONSTRAINT "consignment_lines_lot_id_fkey" FOREIGN KEY ("lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "regular_sales" ADD CONSTRAINT "regular_sales_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "buyers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "regular_sales" ADD CONSTRAINT "regular_sales_courier_id_fkey" FOREIGN KEY ("courier_id") REFERENCES "couriers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "regular_sales" ADD CONSTRAINT "regular_sales_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "regular_sale_lines" ADD CONSTRAINT "regular_sale_lines_regular_sale_id_fkey" FOREIGN KEY ("regular_sale_id") REFERENCES "regular_sales"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "regular_sale_lines" ADD CONSTRAINT "regular_sale_lines_lot_id_fkey" FOREIGN KEY ("lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "jit_sales" ADD CONSTRAINT "jit_sales_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "buyers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "jit_sales" ADD CONSTRAINT "jit_sales_courier_id_fkey" FOREIGN KEY ("courier_id") REFERENCES "couriers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "jit_sales" ADD CONSTRAINT "jit_sales_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "jit_sale_lines" ADD CONSTRAINT "jit_sale_lines_jit_sale_id_fkey" FOREIGN KEY ("jit_sale_id") REFERENCES "jit_sales"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "jit_sale_lines" ADD CONSTRAINT "jit_sale_lines_grade_id_fkey" FOREIGN KEY ("grade_id") REFERENCES "grades"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "jit_sale_lines" ADD CONSTRAINT "jit_sale_lines_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "jit_sale_lines" ADD CONSTRAINT "jit_sale_lines_profit_share_scheme_id_fkey" FOREIGN KEY ("profit_share_scheme_id") REFERENCES "profit_share_schemes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "stock_adjustments" ADD CONSTRAINT "stock_adjustments_lot_id_fkey" FOREIGN KEY ("lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "stock_adjustments" ADD CONSTRAINT "stock_adjustments_adjustment_reason_id_fkey" FOREIGN KEY ("adjustment_reason_id") REFERENCES "adjustment_reasons"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "stock_adjustments" ADD CONSTRAINT "stock_adjustments_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_transformations" ADD CONSTRAINT "lot_transformations_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_transformation_inputs" ADD CONSTRAINT "lot_transformation_inputs_transformation_id_fkey" FOREIGN KEY ("transformation_id") REFERENCES "lot_transformations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_transformation_inputs" ADD CONSTRAINT "lot_transformation_inputs_source_lot_id_fkey" FOREIGN KEY ("source_lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_transformation_outputs" ADD CONSTRAINT "lot_transformation_outputs_transformation_id_fkey" FOREIGN KEY ("transformation_id") REFERENCES "lot_transformations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_transformation_outputs" ADD CONSTRAINT "lot_transformation_outputs_result_lot_id_fkey" FOREIGN KEY ("result_lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_analyses" ADD CONSTRAINT "purchase_analyses_purchase_id_fkey" FOREIGN KEY ("purchase_id") REFERENCES "purchases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_analysis_cost_entries" ADD CONSTRAINT "purchase_analysis_cost_entries_analysis_id_fkey" FOREIGN KEY ("analysis_id") REFERENCES "purchase_analyses"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_purchase_allocations" ADD CONSTRAINT "lot_purchase_allocations_lot_id_fkey" FOREIGN KEY ("lot_id") REFERENCES "inventory_lots"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_purchase_allocations" ADD CONSTRAINT "lot_purchase_allocations_purchase_id_fkey" FOREIGN KEY ("purchase_id") REFERENCES "purchases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_purchase_allocations" ADD CONSTRAINT "lot_purchase_allocations_purchase_line_id_fkey" FOREIGN KEY ("purchase_line_id") REFERENCES "purchase_lines"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_realization_entries" ADD CONSTRAINT "purchase_realization_entries_purchase_id_fkey" FOREIGN KEY ("purchase_id") REFERENCES "purchases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_realization_entries" ADD CONSTRAINT "purchase_realization_entries_lot_id_fkey" FOREIGN KEY ("lot_id") REFERENCES "inventory_lots"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_realization_entries" ADD CONSTRAINT "purchase_realization_entries_allocation_id_fkey" FOREIGN KEY ("allocation_id") REFERENCES "lot_purchase_allocations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_realization_summaries" ADD CONSTRAINT "purchase_realization_summaries_purchase_id_fkey" FOREIGN KEY ("purchase_id") REFERENCES "purchases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_washings" ADD CONSTRAINT "lot_washings_lot_id_fkey" FOREIGN KEY ("lot_id") REFERENCES "inventory_lots"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_washings" ADD CONSTRAINT "lot_washings_washing_place_id_fkey" FOREIGN KEY ("washing_place_id") REFERENCES "washing_places"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_washings" ADD CONSTRAINT "lot_washings_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_washings" ADD CONSTRAINT "lot_washings_completed_by_fkey" FOREIGN KEY ("completed_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "company_bank_accounts" ADD CONSTRAINT "company_bank_accounts_app_setting_id_fkey" FOREIGN KEY ("app_setting_id") REFERENCES "app_settings"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "company_bank_accounts" ADD CONSTRAINT "company_bank_accounts_bank_id_fkey" FOREIGN KEY ("bank_id") REFERENCES "banks"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7741ee7 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,1117 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Role { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(50) + name String @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + users User[] + + @@map("roles") +} + +model User { + id BigInt @id @default(autoincrement()) + roleId BigInt @map("role_id") + role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) + name String @db.VarChar(150) + username String? @unique @db.VarChar(50) + email String? @unique @db.VarChar(150) + emailVerifiedAt DateTime? @map("email_verified_at") + phone String? @db.VarChar(50) + passwordHash String? @map("password_hash") @db.VarChar(255) + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + purchases Purchase[] + receipts Receipt[] + createdTransformations LotTransformation[] @relation("TransformationCreatedBy") + createdStockAdjustments StockAdjustment[] @relation("StockAdjustmentCreatedBy") + createdWashings LotWashing[] @relation("WashingCreatedBy") + completedWashings LotWashing[] @relation("WashingCompletedBy") + createdConsignments Consignment[] @relation("ConsignmentCreatedBy") + createdRegularSales RegularSale[] @relation("RegularSaleCreatedBy") + createdJitSales JitSale[] @relation("JitSaleCreatedBy") + createdFundRequests FundRequest[] @relation("FundRequestCreatedBy") + passwordResetTokens PasswordResetToken[] + emailVerificationTokens EmailVerificationToken[] + auditTrails AuditTrail[] + + @@map("users") +} + +model AuditTrail { + id BigInt @id @default(autoincrement()) + userId BigInt? @map("user_id") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + action String @db.VarChar(100) + entityType String @map("entity_type") @db.VarChar(100) + entityId String? @map("entity_id") @db.VarChar(100) + method String @db.VarChar(10) + pathname String @db.VarChar(255) + statusCode Int @map("status_code") + summary String? @db.VarChar(255) + metadata Json? + occurredAt DateTime @default(now()) @map("occurred_at") + + @@index([action, occurredAt]) + @@index([entityType, occurredAt]) + @@index([userId, occurredAt]) + @@map("audit_trails") +} + +model PasswordResetToken { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokenHash String @unique @map("token_hash") @db.VarChar(255) + purpose String @default("RESET_PASSWORD") @db.VarChar(50) + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + + @@map("password_reset_tokens") +} + +model EmailVerificationToken { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokenHash String @unique @map("token_hash") @db.VarChar(255) + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + + @@map("email_verification_tokens") +} + +model Buyer { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(50) + name String @db.VarChar(150) + phone String? @db.VarChar(50) + email String? @db.VarChar(150) + bankName String? @map("bank_name") @db.VarChar(100) + bankAccountNumber String? @map("bank_account_number") @db.VarChar(100) + address String? + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + contactPeople BuyerContactPerson[] + consignmentsAsBuyer Consignment[] @relation("ConsignmentBuyer") + regularSalesAsBuyer RegularSale[] @relation("RegularSaleBuyer") + jitSalesAsBuyer JitSale[] @relation("JitSaleBuyer") + + @@map("buyers") +} + +model BuyerContactPerson { + id BigInt @id @default(autoincrement()) + buyerId BigInt @map("buyer_id") + buyer Buyer @relation(fields: [buyerId], references: [id], onDelete: Cascade) + name String @db.VarChar(150) + mobilePhone String? @map("mobile_phone") @db.VarChar(50) + email String? @db.VarChar(150) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("buyer_contact_people") +} + +model Employee { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + name String @db.VarChar(150) + email String? @db.VarChar(150) + mobile String? @db.VarChar(50) + position String @db.VarChar(150) + address String? + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + receivedPurchases Purchase[] + + @@map("employees") +} + +model Courier { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + name String @db.VarChar(150) + address String? + phone String? @db.VarChar(50) + website String? @db.VarChar(255) + email String? @db.VarChar(150) + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + purchases Purchase[] + regularSales RegularSale[] + jitSales JitSale[] + + @@map("couriers") +} + +model WashingPlace { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + name String @db.VarChar(150) + address String? + phone String? @db.VarChar(50) + website String? @db.VarChar(255) + email String? @db.VarChar(150) + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + contactPeople WashingPlaceContactPerson[] + washings LotWashing[] + + @@map("washing_places") +} + +model WashingPlaceContactPerson { + id BigInt @id @default(autoincrement()) + washingPlaceId BigInt @map("washing_place_id") + washingPlace WashingPlace @relation(fields: [washingPlaceId], references: [id], onDelete: Cascade) + name String @db.VarChar(150) + mobilePhone String? @map("mobile_phone") @db.VarChar(50) + email String? @db.VarChar(150) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("washing_place_contact_people") +} + +model Grade { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + legacyCode String? @unique @map("legacy_code") @db.VarChar(20) + isMangkok Boolean @default(false) @map("is_mangkok") + name String @db.VarChar(150) + description String? + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + buyPriceStandards GradeBuyPriceStandard[] + sellPriceStandards GradeSellPriceStandard[] + purchaseLines PurchaseLine[] + receiptLines ReceiptLine[] + lots InventoryLot[] + jitSaleLines JitSaleLine[] + + @@map("grades") +} + +model GradeBuyPriceStandard { + id BigInt @id @default(autoincrement()) + gradeId BigInt @map("grade_id") + grade Grade @relation(fields: [gradeId], references: [id], onDelete: Cascade) + startDate DateTime @map("start_date") @db.Date + endDate DateTime? @map("end_date") @db.Date + minPrice Decimal @map("min_price") @db.Decimal(18, 2) + maxPrice Decimal @map("max_price") @db.Decimal(18, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("grade_buy_price_standards") +} + +model GradeSellPriceStandard { + id BigInt @id @default(autoincrement()) + gradeId BigInt @map("grade_id") + grade Grade @relation(fields: [gradeId], references: [id], onDelete: Cascade) + startDate DateTime @map("start_date") @db.Date + endDate DateTime? @map("end_date") @db.Date + minPrice Decimal @map("min_price") @db.Decimal(18, 2) + maxPrice Decimal @map("max_price") @db.Decimal(18, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("grade_sell_price_standards") +} + +model Warehouse { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(50) + name String @db.VarChar(100) + address String? + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + locations WarehouseLocation[] + receiptLines ReceiptLine[] + lots InventoryLot[] + purchaseLines PurchaseLine[] + + @@map("warehouses") +} + +model Unit { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + name String @db.VarChar(50) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + purchaseLines PurchaseLine[] + receiptLines ReceiptLine[] + lots InventoryLot[] + + @@map("units") +} + +model Currency { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(10) + name String @db.VarChar(100) + description String? + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("currencies") +} + +model Bank { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(3) + name String @unique @db.VarChar(150) + address String? + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + agentBankAccounts AgentBankAccount[] + salesBankAccounts SalesBankAccount[] + companyBankAccounts CompanyBankAccount[] + + @@map("banks") +} + +model ProfitShareScheme { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + name String @db.VarChar(150) + shareAgent Decimal @map("share_agent") @db.Decimal(5, 2) + shareCompany Decimal @map("share_company") @db.Decimal(5, 2) + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + agents Agent[] + purchases Purchase[] + jitSaleLines JitSaleLine[] + + @@map("profit_share_schemes") +} + +model Agent { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + name String @db.VarChar(150) + identityType String @map("identity_type") @db.VarChar(20) + identityNumber String @map("identity_number") @db.VarChar(100) + mobilePhone String? @map("mobile_phone") @db.VarChar(50) + email String? @db.VarChar(150) + address String? + notes String? + joinDate DateTime @map("join_date") @db.Date + profitShareSchemeId BigInt @map("profit_share_scheme_id") + profitShareScheme ProfitShareScheme @relation(fields: [profitShareSchemeId], references: [id], onDelete: Restrict) + currentBalance Decimal @default(0) @map("current_balance") @db.Decimal(18, 2) + capitalBalance Decimal @default(0) @map("capital_balance") @db.Decimal(18, 2) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + bankAccounts AgentBankAccount[] + balanceMutations AgentBalanceMutation[] + fundRequests FundRequest[] + purchases Purchase[] @relation("PurchaseAgent") + buyoutPurchases Purchase[] @relation("PurchaseBuyoutSourceAgent") + jitSaleLines JitSaleLine[] + + @@unique([identityType, identityNumber]) + @@map("agents") +} + +model AgentBankAccount { + id BigInt @id @default(autoincrement()) + agentId BigInt @map("agent_id") + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + bankId BigInt @map("bank_id") + bank Bank @relation(fields: [bankId], references: [id], onDelete: Restrict) + accountNumber String @map("account_number") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + fundRequests FundRequest[] + + @@unique([agentId, bankId, accountNumber]) + @@map("agent_bank_accounts") +} + +model AgentBalanceMutation { + id BigInt @id @default(autoincrement()) + agentId BigInt @map("agent_id") + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + balanceType String @map("balance_type") @db.VarChar(30) + direction String @db.VarChar(10) + source String @db.VarChar(50) + amount Decimal @db.Decimal(18, 2) + balanceAfter Decimal @map("balance_after") @db.Decimal(18, 2) + occurredAt DateTime @default(now()) @map("occurred_at") + effectiveDate DateTime? @map("effective_date") @db.Date + referenceType String? @map("reference_type") @db.VarChar(50) + referenceId String? @map("reference_id") @db.VarChar(100) + referenceNo String? @map("reference_no") @db.VarChar(100) + notes String? + metadata Json? + + @@index([agentId, occurredAt]) + @@index([agentId, balanceType, occurredAt]) + @@map("agent_balance_mutations") +} + +model FundRequest { + id BigInt @id @default(autoincrement()) + requestNo String @unique @map("request_no") @db.VarChar(50) + referenceNo String @map("reference_no") @db.VarChar(100) + transferType String @map("transfer_type") @db.VarChar(30) + agentId BigInt @map("agent_id") + agent Agent @relation(fields: [agentId], references: [id], onDelete: Restrict) + agentBankAccountId BigInt @map("agent_bank_account_id") + agentBankAccount AgentBankAccount @relation(fields: [agentBankAccountId], references: [id], onDelete: Restrict) + companyBankAccountId BigInt? @map("company_bank_account_id") + companyBankAccount CompanyBankAccount? @relation(fields: [companyBankAccountId], references: [id], onDelete: Restrict) + agentBankNameSnapshot String @map("agent_bank_name_snapshot") @db.VarChar(150) + agentAccountNumberSnapshot String @map("agent_account_number_snapshot") @db.VarChar(100) + companyBankNameSnapshot String @map("company_bank_name_snapshot") @db.VarChar(150) + companyAccountNumberSnapshot String @map("company_account_number_snapshot") @db.VarChar(100) + amount Decimal @db.Decimal(18, 2) + currencyCode String @default("IDR") @map("currency_code") @db.VarChar(10) + transferredAt DateTime @map("transferred_at") + transferProofFileUrl String? @map("transfer_proof_file_url") @db.VarChar(255) + status String @default("SUBMITTED") @db.VarChar(20) + createdById BigInt @map("created_by") + createdBy User @relation("FundRequestCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([agentId, transferredAt]) + @@index([transferType, transferredAt]) + @@map("fund_requests") +} + +model Sales { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(20) + name String @db.VarChar(150) + identityType String @map("identity_type") @db.VarChar(20) + identityNumber String @map("identity_number") @db.VarChar(100) + mobilePhone String? @map("mobile_phone") @db.VarChar(50) + email String? @db.VarChar(150) + address String? + notes String? + joinDate DateTime @map("join_date") @db.Date + commissionBalance Decimal @default(0) @map("commission_balance") @db.Decimal(18, 2) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + bankAccounts SalesBankAccount[] + consignments Consignment[] @relation("ConsignmentSales") + commissionMutations SalesCommissionMutation[] + + @@unique([identityType, identityNumber]) + @@map("sales") +} + +model SalesCommissionMutation { + id BigInt @id @default(autoincrement()) + salesId BigInt @map("sales_id") + sales Sales @relation(fields: [salesId], references: [id], onDelete: Cascade) + source String @db.VarChar(50) + direction String @db.VarChar(10) + amount Decimal @db.Decimal(18, 2) + balanceAfter Decimal @map("balance_after") @db.Decimal(18, 2) + occurredAt DateTime @default(now()) @map("occurred_at") + effectiveDate DateTime? @map("effective_date") @db.Date + referenceType String? @map("reference_type") @db.VarChar(50) + referenceId String? @map("reference_id") @db.VarChar(100) + referenceNo String? @map("reference_no") @db.VarChar(100) + notes String? + metadata Json? + + @@index([salesId, occurredAt]) + @@map("sales_commission_mutations") +} + +model SalesBankAccount { + id BigInt @id @default(autoincrement()) + salesId BigInt @map("sales_id") + sales Sales @relation(fields: [salesId], references: [id], onDelete: Cascade) + bankId BigInt @map("bank_id") + bank Bank @relation(fields: [bankId], references: [id], onDelete: Restrict) + accountNumber String @map("account_number") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([salesId, bankId, accountNumber]) + @@map("sales_bank_accounts") +} + +model WarehouseLocation { + id BigInt @id @default(autoincrement()) + warehouseId BigInt @map("warehouse_id") + warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict) + code String @db.VarChar(50) + name String @db.VarChar(100) + locationType String? @map("location_type") @db.VarChar(50) + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + receiptLines ReceiptLine[] + lots InventoryLot[] + purchaseLines PurchaseLine[] + + @@unique([warehouseId, code]) + @@map("warehouse_locations") +} + +model AdjustmentReason { + id BigInt @id @default(autoincrement()) + code String @unique @db.VarChar(50) + name String @db.VarChar(100) + category String @db.VarChar(50) + status String @default("ACTIVE") @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + stockAdjustments StockAdjustment[] + + @@map("adjustment_reasons") +} + +model Purchase { + id BigInt @id @default(autoincrement()) + purchaseNo String @unique @map("purchase_no") @db.VarChar(50) + purchaseType String @default("REGULAR") @map("purchase_type") @db.VarChar(30) + purchaseDate DateTime @map("purchase_date") @db.Date + supplierInvoiceNo String? @map("supplier_invoice_no") @db.VarChar(100) + agentId BigInt? @map("agent_id") + agent Agent? @relation("PurchaseAgent", fields: [agentId], references: [id], onDelete: Restrict) + buyoutSourceAgentId BigInt? @map("buyout_source_agent_id") + buyoutSourceAgent Agent? @relation("PurchaseBuyoutSourceAgent", fields: [buyoutSourceAgentId], references: [id], onDelete: Restrict) + profitShareSchemeId BigInt? @map("profit_share_scheme_id") + profitShareScheme ProfitShareScheme? @relation(fields: [profitShareSchemeId], references: [id], onDelete: Restrict) + courierId BigInt? @map("courier_id") + courier Courier? @relation(fields: [courierId], references: [id], onDelete: Restrict) + receivedByEmployeeId BigInt? @map("received_by_employee_id") + receivedByEmployee Employee? @relation(fields: [receivedByEmployeeId], references: [id], onDelete: Restrict) + receivedAt DateTime? @map("received_at") + moistureBuyPercent Decimal? @map("moisture_buy_percent") @db.Decimal(7, 2) + moistureReceivedPercent Decimal? @map("moisture_received_percent") @db.Decimal(7, 2) + aboveAverageRatioPercent Decimal? @map("above_average_ratio_percent") @db.Decimal(7, 2) + mkSharePercent Decimal? @map("mk_share_percent") @db.Decimal(7, 2) + nonMkSharePercent Decimal? @map("non_mk_share_percent") @db.Decimal(7, 2) + shippingCost Decimal? @map("shipping_cost") @db.Decimal(18, 2) + incomingOperationalCost Decimal? @map("incoming_operational_cost") @db.Decimal(18, 2) + afterArrivalOperationalCost Decimal? @map("after_arrival_operational_cost") @db.Decimal(18, 2) + status String @default("DRAFT") @db.VarChar(20) + notes String? + createdById BigInt @map("created_by") + createdBy User @relation(fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lines PurchaseLine[] + receipts Receipt[] + lots InventoryLot[] + analysis PurchaseAnalysis? + lotAllocations LotPurchaseAllocation[] + realizationEntries PurchaseRealizationEntry[] + realizationSummary PurchaseRealizationSummary? + + @@map("purchases") +} + +model PurchaseLine { + id BigInt @id @default(autoincrement()) + purchaseId BigInt @map("purchase_id") + purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade) + gradeId BigInt? @map("grade_id") + grade Grade? @relation(fields: [gradeId], references: [id], onDelete: Restrict) + sourceLotId BigInt? @map("source_lot_id") + sourceLot InventoryLot? @relation("OfficeBuyoutSourceLot", fields: [sourceLotId], references: [id], onDelete: Restrict) + qtyOrdered Decimal @map("qty_ordered") @db.Decimal(18, 3) + purchaseMoisturePercent Decimal? @map("purchase_moisture_percent") @db.Decimal(7, 2) + qtyReceived Decimal @default(0) @map("qty_received") @db.Decimal(18, 3) + qtyAccepted Decimal @default(0) @map("qty_accepted") @db.Decimal(18, 3) + qtyRejected Decimal @map("qty_rejected") @db.Decimal(18, 3) + moistureReceivedPercent Decimal? @map("moisture_received_percent") @db.Decimal(7, 2) + unitId BigInt @map("unit_id") + unit Unit @relation(fields: [unitId], references: [id], onDelete: Restrict) + unitPrice Decimal @map("unit_price") @db.Decimal(18, 2) + buyoutMalUnitPriceSnapshot Decimal? @map("buyout_mal_unit_price_snapshot") @db.Decimal(18, 2) + buyoutAgentSharePercent Decimal? @map("buyout_agent_share_percent") @db.Decimal(5, 2) + buyoutProfitAmount Decimal? @map("buyout_profit_amount") @db.Decimal(18, 2) + buyoutAgentCommission Decimal? @map("buyout_agent_commission") @db.Decimal(18, 2) + marketReferencePrice Decimal? @map("market_reference_price") @db.Decimal(18, 2) + unitCost Decimal @default(0) @map("unit_cost") @db.Decimal(18, 2) + malUnitPrice Decimal? @map("mal_unit_price") @db.Decimal(18, 2) + subtotal Decimal @db.Decimal(18, 2) + classificationStatus String @default("FINAL") @map("classification_status") @db.VarChar(20) + warehouseId BigInt? @map("warehouse_id") + warehouse Warehouse? @relation(fields: [warehouseId], references: [id], onDelete: Restrict) + warehouseLocationId BigInt? @map("warehouse_location_id") + warehouseLocation WarehouseLocation? @relation(fields: [warehouseLocationId], references: [id], onDelete: Restrict) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + receiptLines ReceiptLine[] + lots InventoryLot[] + lotAllocations LotPurchaseAllocation[] + + @@map("purchase_lines") +} + +model Receipt { + id BigInt @id @default(autoincrement()) + receiptNo String @unique @map("receipt_no") @db.VarChar(50) + purchaseId BigInt @map("purchase_id") + purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Restrict) + receiptDate DateTime @map("receipt_date") @db.Date + status String @default("DRAFT") @db.VarChar(20) + notes String? + receivedById BigInt @map("received_by") + receivedBy User @relation(fields: [receivedById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lines ReceiptLine[] + lots InventoryLot[] + + @@map("receipts") +} + +model ReceiptLine { + id BigInt @id @default(autoincrement()) + receiptId BigInt @map("receipt_id") + receipt Receipt @relation(fields: [receiptId], references: [id], onDelete: Cascade) + purchaseLineId BigInt @map("purchase_line_id") + purchaseLine PurchaseLine @relation(fields: [purchaseLineId], references: [id], onDelete: Restrict) + gradeId BigInt? @map("grade_id") + grade Grade? @relation(fields: [gradeId], references: [id], onDelete: Restrict) + qtyReceived Decimal @map("qty_received") @db.Decimal(18, 3) + qtyAccepted Decimal @map("qty_accepted") @db.Decimal(18, 3) + qtyRejected Decimal @default(0) @map("qty_rejected") @db.Decimal(18, 3) + moisturePercent Decimal? @map("moisture_percent") @db.Decimal(7, 2) + unitId BigInt @map("unit_id") + unit Unit @relation(fields: [unitId], references: [id], onDelete: Restrict) + unitCost Decimal @map("unit_cost") @db.Decimal(18, 2) + warehouseId BigInt @map("warehouse_id") + warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict) + warehouseLocationId BigInt? @map("warehouse_location_id") + warehouseLocation WarehouseLocation? @relation(fields: [warehouseLocationId], references: [id], onDelete: Restrict) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lots InventoryLot[] + + @@map("receipt_lines") +} + +model InventoryLot { + id BigInt @id @default(autoincrement()) + lotCode String @unique @map("lot_code") @db.VarChar(100) + parentLotId BigInt? @map("parent_lot_id") + parentLot InventoryLot? @relation("LotHierarchy", fields: [parentLotId], references: [id], onDelete: Restrict) + childLots InventoryLot[] @relation("LotHierarchy") + sourceType String @map("source_type") @db.VarChar(30) + sourceRefId BigInt? @map("source_ref_id") + purchaseId BigInt? @map("purchase_id") + purchase Purchase? @relation(fields: [purchaseId], references: [id], onDelete: Restrict) + purchaseLineId BigInt? @map("purchase_line_id") + purchaseLine PurchaseLine? @relation(fields: [purchaseLineId], references: [id], onDelete: Restrict) + receiptId BigInt? @map("receipt_id") + receipt Receipt? @relation(fields: [receiptId], references: [id], onDelete: Restrict) + receiptLineId BigInt? @map("receipt_line_id") + receiptLine ReceiptLine? @relation(fields: [receiptLineId], references: [id], onDelete: Restrict) + gradeId BigInt? @map("grade_id") + grade Grade? @relation(fields: [gradeId], references: [id], onDelete: Restrict) + warehouseId BigInt @map("warehouse_id") + warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict) + warehouseLocationId BigInt? @map("warehouse_location_id") + warehouseLocation WarehouseLocation? @relation(fields: [warehouseLocationId], references: [id], onDelete: Restrict) + originalQty Decimal @map("original_qty") @db.Decimal(18, 3) + availableQty Decimal @map("available_qty") @db.Decimal(18, 3) + reservedQty Decimal @default(0) @map("reserved_qty") @db.Decimal(18, 3) + damagedQty Decimal @default(0) @map("damaged_qty") @db.Decimal(18, 3) + shrinkageQty Decimal @default(0) @map("shrinkage_qty") @db.Decimal(18, 3) + finalMoisturePercent Decimal? @map("final_moisture_percent") @db.Decimal(7, 2) + aboveAverageRatioPercent Decimal? @map("above_average_ratio_percent") @db.Decimal(7, 2) + unitId BigInt @map("unit_id") + unit Unit @relation(fields: [unitId], references: [id], onDelete: Restrict) + unitCost Decimal @map("unit_cost") @db.Decimal(18, 2) + receivedAt DateTime @map("received_at") + status String @default("ACTIVE") @db.VarChar(20) + qrCodeValue String? @map("qr_code_value") @db.VarChar(255) + barcodeValue String? @map("barcode_value") @db.VarChar(255) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + sourcedBuyoutLines PurchaseLine[] @relation("OfficeBuyoutSourceLot") + transformationInputs LotTransformationInput[] @relation("TransformationInputLot") + transformationOutputs LotTransformationOutput[] @relation("TransformationOutputLot") + stockAdjustments StockAdjustment[] + washings LotWashing[] + consignmentLines ConsignmentLine[] + regularSaleLines RegularSaleLine[] + purchaseAllocations LotPurchaseAllocation[] + realizationEntries PurchaseRealizationEntry[] + + @@map("inventory_lots") +} + +model Consignment { + id BigInt @id @default(autoincrement()) + consignmentNo String @unique @map("consignment_no") @db.VarChar(50) + consignmentDate DateTime @map("consignment_date") @db.Date + salesId BigInt @map("sales_id") + sales Sales @relation("ConsignmentSales", fields: [salesId], references: [id], onDelete: Restrict) + buyerId BigInt @map("buyer_id") + buyer Buyer @relation("ConsignmentBuyer", fields: [buyerId], references: [id], onDelete: Restrict) + status String @default("OPEN") @db.VarChar(20) + notes String? + createdById BigInt @map("created_by") + createdBy User @relation("ConsignmentCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lines ConsignmentLine[] + + @@map("consignments") +} + +model ConsignmentLine { + id BigInt @id @default(autoincrement()) + consignmentId BigInt @map("consignment_id") + consignment Consignment @relation(fields: [consignmentId], references: [id], onDelete: Cascade) + lotId BigInt @map("lot_id") + lot InventoryLot @relation(fields: [lotId], references: [id], onDelete: Restrict) + qtyConsigned Decimal @map("qty_consigned") @db.Decimal(18, 3) + availableQtySnapshot Decimal @map("available_qty_snapshot") @db.Decimal(18, 3) + malUnitPriceSnapshot Decimal? @map("mal_unit_price_snapshot") @db.Decimal(18, 2) + agentNameSnapshot String? @map("agent_name_snapshot") @db.VarChar(150) + agentSharePercent Decimal? @map("agent_share_percent") @db.Decimal(5, 2) + status String @default("OPEN") @db.VarChar(20) + notes String? + closeDate DateTime? @map("close_date") @db.Date + sellingPrice Decimal? @map("selling_price") @db.Decimal(18, 2) + qtySold Decimal @default(0) @map("qty_sold") @db.Decimal(18, 3) + qtyReturned Decimal @default(0) @map("qty_returned") @db.Decimal(18, 3) + qtyShrinkage Decimal @default(0) @map("qty_shrinkage") @db.Decimal(18, 3) + salesCommission Decimal @default(0) @map("sales_commission") @db.Decimal(18, 2) + agentCommission Decimal @default(0) @map("agent_commission") @db.Decimal(18, 2) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("consignment_lines") +} + +model RegularSale { + id BigInt @id @default(autoincrement()) + saleNo String @unique @map("sale_no") @db.VarChar(50) + saleDate DateTime @map("sale_date") @db.Date + buyerId BigInt @map("buyer_id") + buyer Buyer @relation("RegularSaleBuyer", fields: [buyerId], references: [id], onDelete: Restrict) + buyerCurrencyCode String @map("buyer_currency_code") @db.VarChar(10) + companyCurrencyCode String @map("company_currency_code") @db.VarChar(10) + exchangeRate Decimal? @map("exchange_rate") @db.Decimal(18, 6) + courierId BigInt? @map("courier_id") + courier Courier? @relation(fields: [courierId], references: [id], onDelete: Restrict) + shippingCostBuyer Decimal @default(0) @map("shipping_cost_buyer") @db.Decimal(18, 2) + shippingCostCompany Decimal @default(0) @map("shipping_cost_company") @db.Decimal(18, 2) + shippingReceiptFileUrl String? @map("shipping_receipt_file_url") @db.VarChar(255) + closeDate DateTime? @map("close_date") @db.Date + totalNominalBuyer Decimal @default(0) @map("total_nominal_buyer") @db.Decimal(18, 2) + totalNominalCompany Decimal @default(0) @map("total_nominal_company") @db.Decimal(18, 2) + totalAgentCommission Decimal @default(0) @map("total_agent_commission") @db.Decimal(18, 2) + status String @default("IN_PROGRESS") @db.VarChar(20) + notes String? + createdById BigInt @map("created_by") + createdBy User @relation("RegularSaleCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lines RegularSaleLine[] + + @@map("regular_sales") +} + +model RegularSaleLine { + id BigInt @id @default(autoincrement()) + regularSaleId BigInt @map("regular_sale_id") + regularSale RegularSale @relation(fields: [regularSaleId], references: [id], onDelete: Cascade) + lotId BigInt @map("lot_id") + lot InventoryLot @relation(fields: [lotId], references: [id], onDelete: Restrict) + availableQtySnapshot Decimal @map("available_qty_snapshot") @db.Decimal(18, 3) + malUnitPriceSnapshot Decimal? @map("mal_unit_price_snapshot") @db.Decimal(18, 2) + agentId BigInt? @map("agent_id") + agentNameSnapshot String? @map("agent_name_snapshot") @db.VarChar(150) + agentSharePercent Decimal? @map("agent_share_percent") @db.Decimal(5, 2) + qtyPlanned Decimal @map("qty_planned") @db.Decimal(18, 3) + sellingPricePlanned Decimal @map("selling_price_planned") @db.Decimal(18, 2) + qtyActualSold Decimal? @map("qty_actual_sold") @db.Decimal(18, 3) + qtyReturned Decimal? @map("qty_returned") @db.Decimal(18, 3) + qtyShrinkage Decimal? @map("qty_shrinkage") @db.Decimal(18, 3) + sellingPriceActual Decimal? @map("selling_price_actual") @db.Decimal(18, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([regularSaleId, lotId]) + @@map("regular_sale_lines") +} + +model JitSale { + id BigInt @id @default(autoincrement()) + saleNo String @unique @map("sale_no") @db.VarChar(50) + saleDate DateTime @map("sale_date") @db.Date + buyerId BigInt @map("buyer_id") + buyer Buyer @relation("JitSaleBuyer", fields: [buyerId], references: [id], onDelete: Restrict) + buyerCurrencyCode String @map("buyer_currency_code") @db.VarChar(10) + companyCurrencyCode String @map("company_currency_code") @db.VarChar(10) + exchangeRate Decimal? @map("exchange_rate") @db.Decimal(18, 6) + courierId BigInt? @map("courier_id") + courier Courier? @relation(fields: [courierId], references: [id], onDelete: Restrict) + shippingCostBuyer Decimal @default(0) @map("shipping_cost_buyer") @db.Decimal(18, 2) + shippingCostCompany Decimal @default(0) @map("shipping_cost_company") @db.Decimal(18, 2) + shippingReceiptFileUrl String? @map("shipping_receipt_file_url") @db.VarChar(255) + closeDate DateTime? @map("close_date") @db.Date + totalNominalBuyer Decimal @default(0) @map("total_nominal_buyer") @db.Decimal(18, 2) + totalNominalCompany Decimal @default(0) @map("total_nominal_company") @db.Decimal(18, 2) + totalAgentCommission Decimal @default(0) @map("total_agent_commission") @db.Decimal(18, 2) + status String @default("OPEN") @db.VarChar(20) + notes String? + createdById BigInt @map("created_by") + createdBy User @relation("JitSaleCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lines JitSaleLine[] + + @@map("jit_sales") +} + +model JitSaleLine { + id BigInt @id @default(autoincrement()) + jitSaleId BigInt @map("jit_sale_id") + jitSale JitSale @relation(fields: [jitSaleId], references: [id], onDelete: Cascade) + gradeId BigInt @map("grade_id") + grade Grade @relation(fields: [gradeId], references: [id], onDelete: Restrict) + qtyPlanned Decimal @map("qty_planned") @db.Decimal(18, 3) + qtyActualSold Decimal? @map("qty_actual_sold") @db.Decimal(18, 3) + malUnitPrice Decimal @map("mal_unit_price") @db.Decimal(18, 2) + sellingPricePlanned Decimal @map("selling_price_planned") @db.Decimal(18, 2) + sellingPriceActual Decimal? @map("selling_price_actual") @db.Decimal(18, 2) + agentId BigInt? @map("agent_id") + agent Agent? @relation(fields: [agentId], references: [id], onDelete: Restrict) + agentNameSnapshot String? @map("agent_name_snapshot") @db.VarChar(150) + profitShareSchemeId BigInt? @map("profit_share_scheme_id") + profitShareScheme ProfitShareScheme? @relation(fields: [profitShareSchemeId], references: [id], onDelete: Restrict) + profitShareSchemeName String? @map("profit_share_scheme_name") @db.VarChar(150) + agentSharePercent Decimal? @map("agent_share_percent") @db.Decimal(5, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("jit_sale_lines") +} + +model StockAdjustment { + id BigInt @id @default(autoincrement()) + adjustmentNo String @unique @map("adjustment_no") @db.VarChar(50) + lotId BigInt @map("lot_id") + lot InventoryLot @relation(fields: [lotId], references: [id], onDelete: Restrict) + adjustmentReasonId BigInt @map("adjustment_reason_id") + adjustmentReason AdjustmentReason @relation(fields: [adjustmentReasonId], references: [id], onDelete: Restrict) + adjustmentDate DateTime @map("adjustment_date") @db.Date + qtyChange Decimal @map("qty_change") @db.Decimal(18, 3) + availableQtyBefore Decimal @map("available_qty_before") @db.Decimal(18, 3) + availableQtyAfter Decimal @map("available_qty_after") @db.Decimal(18, 3) + notes String? + createdById BigInt @map("created_by") + createdBy User @relation("StockAdjustmentCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("stock_adjustments") +} + +model LotTransformation { + id BigInt @id @default(autoincrement()) + transformationNo String @unique @map("transformation_no") @db.VarChar(50) + transformationType String @default("MIX") @map("transformation_type") @db.VarChar(30) + transformationDate DateTime @map("transformation_date") @db.Date + status String @default("FINALIZED") @db.VarChar(20) + remainderMode String? @map("remainder_mode") @db.VarChar(30) + remainderQty Decimal? @map("remainder_qty") @db.Decimal(18, 3) + processingLossMode String? @map("processing_loss_mode") @db.VarChar(30) + processingLossQty Decimal? @map("processing_loss_qty") @db.Decimal(18, 3) + notes String? + createdById BigInt @map("created_by") + createdBy User @relation("TransformationCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + inputs LotTransformationInput[] + outputs LotTransformationOutput[] + + @@map("lot_transformations") +} + +model LotTransformationInput { + id BigInt @id @default(autoincrement()) + transformationId BigInt @map("transformation_id") + transformation LotTransformation @relation(fields: [transformationId], references: [id], onDelete: Cascade) + sourceLotId BigInt @map("source_lot_id") + sourceLot InventoryLot @relation("TransformationInputLot", fields: [sourceLotId], references: [id], onDelete: Restrict) + qtyUsed Decimal @map("qty_used") @db.Decimal(18, 3) + unitCostSnapshot Decimal @map("unit_cost_snapshot") @db.Decimal(18, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([transformationId, sourceLotId]) + @@map("lot_transformation_inputs") +} + +model LotTransformationOutput { + id BigInt @id @default(autoincrement()) + transformationId BigInt @map("transformation_id") + transformation LotTransformation @relation(fields: [transformationId], references: [id], onDelete: Cascade) + resultLotId BigInt @unique @map("result_lot_id") + resultLot InventoryLot @relation("TransformationOutputLot", fields: [resultLotId], references: [id], onDelete: Restrict) + qtyProduced Decimal @map("qty_produced") @db.Decimal(18, 3) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("lot_transformation_outputs") +} + +model PurchaseAnalysis { + id BigInt @id @default(autoincrement()) + purchaseId BigInt @unique @map("purchase_id") + purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade) + status String @default("DRAFT") @db.VarChar(20) + weightBuy Decimal? @map("weight_buy") @db.Decimal(18, 3) + weightReceived Decimal? @map("weight_received") @db.Decimal(18, 3) + weightFinal Decimal? @map("weight_final") @db.Decimal(18, 3) + moistureBuyPercent Decimal? @map("moisture_buy_percent") @db.Decimal(7, 2) + moistureReceivedPercent Decimal? @map("moisture_received_percent") @db.Decimal(7, 2) + moistureFinalPercent Decimal? @map("moisture_final_percent") @db.Decimal(7, 2) + aboveAverageRatioPercent Decimal? @map("above_average_ratio_percent") @db.Decimal(7, 2) + averagePrice Decimal? @map("average_price") @db.Decimal(18, 2) + modalBeli Decimal? @map("modal_beli") @db.Decimal(18, 2) + modalMasuk Decimal? @map("modal_masuk") @db.Decimal(18, 2) + modalJual Decimal? @map("modal_jual") @db.Decimal(18, 2) + modalBarang Decimal? @map("modal_barang") @db.Decimal(18, 2) + totalModalBeli Decimal? @map("total_modal_beli") @db.Decimal(18, 2) + totalModalMal Decimal? @map("total_modal_mal") @db.Decimal(18, 2) + marketReferencePrice Decimal? @map("market_reference_price") @db.Decimal(18, 2) + marketValuationTotal Decimal? @map("market_valuation_total") @db.Decimal(18, 2) + agentProfitShareTotal Decimal @default(0) @map("agent_profit_share_total") @db.Decimal(18, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + costEntries PurchaseAnalysisCostEntry[] + + @@map("purchase_analyses") +} + +model PurchaseAnalysisCostEntry { + id BigInt @id @default(autoincrement()) + analysisId BigInt @map("analysis_id") + analysis PurchaseAnalysis @relation(fields: [analysisId], references: [id], onDelete: Cascade) + costType String @map("cost_type") @db.VarChar(50) + description String? @db.VarChar(255) + amount Decimal @db.Decimal(18, 2) + proofFileUrl String? @map("proof_file_url") @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("purchase_analysis_cost_entries") +} + +model LotPurchaseAllocation { + id BigInt @id @default(autoincrement()) + lotId BigInt @map("lot_id") + lot InventoryLot @relation(fields: [lotId], references: [id], onDelete: Cascade) + purchaseId BigInt @map("purchase_id") + purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade) + purchaseLineId BigInt? @map("purchase_line_id") + purchaseLine PurchaseLine? @relation(fields: [purchaseLineId], references: [id], onDelete: SetNull) + sourceType String @map("source_type") @db.VarChar(30) + sourceRefId BigInt? @map("source_ref_id") + agentIdSnapshot BigInt? @map("agent_id_snapshot") + profitShareSchemeIdSnapshot BigInt? @map("profit_share_scheme_id_snapshot") + qtyAllocated Decimal @map("qty_allocated") @db.Decimal(18, 3) + costTotalAllocated Decimal @map("cost_total_allocated") @db.Decimal(18, 2) + unitCostSnapshot Decimal @map("unit_cost_snapshot") @db.Decimal(18, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + realizationEntries PurchaseRealizationEntry[] + + @@index([lotId]) + @@index([purchaseId]) + @@index([purchaseLineId]) + @@index([sourceType, sourceRefId]) + @@map("lot_purchase_allocations") +} + +model PurchaseRealizationEntry { + id BigInt @id @default(autoincrement()) + purchaseId BigInt @map("purchase_id") + purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade) + lotId BigInt? @map("lot_id") + lot InventoryLot? @relation(fields: [lotId], references: [id], onDelete: SetNull) + allocationId BigInt? @map("allocation_id") + allocation LotPurchaseAllocation? @relation(fields: [allocationId], references: [id], onDelete: SetNull) + eventType String @map("event_type") @db.VarChar(40) + referenceType String @map("reference_type") @db.VarChar(40) + referenceId BigInt? @map("reference_id") + occurredAt DateTime @map("occurred_at") + qtyIn Decimal @default(0) @map("qty_in") @db.Decimal(18, 3) + qtyOut Decimal @default(0) @map("qty_out") @db.Decimal(18, 3) + qtyShrinkage Decimal @default(0) @map("qty_shrinkage") @db.Decimal(18, 3) + amountCost Decimal @default(0) @map("amount_cost") @db.Decimal(18, 2) + amountRevenue Decimal @default(0) @map("amount_revenue") @db.Decimal(18, 2) + amountExpense Decimal @default(0) @map("amount_expense") @db.Decimal(18, 2) + amountProfit Decimal @default(0) @map("amount_profit") @db.Decimal(18, 2) + agentSharePercentSnapshot Decimal? @map("agent_share_percent_snapshot") @db.Decimal(7, 2) + agentAmount Decimal @default(0) @map("agent_amount") @db.Decimal(18, 2) + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([purchaseId, occurredAt]) + @@index([lotId]) + @@index([allocationId]) + @@index([referenceType, referenceId]) + @@index([eventType, occurredAt]) + @@map("purchase_realization_entries") +} + +model PurchaseRealizationSummary { + id BigInt @id @default(autoincrement()) + purchaseId BigInt @unique @map("purchase_id") + purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade) + status String @default("OPEN") @db.VarChar(20) + qtyOpening Decimal @default(0) @map("qty_opening") @db.Decimal(18, 3) + qtyRemaining Decimal @default(0) @map("qty_remaining") @db.Decimal(18, 3) + qtySold Decimal @default(0) @map("qty_sold") @db.Decimal(18, 3) + qtyReturned Decimal @default(0) @map("qty_returned") @db.Decimal(18, 3) + qtyShrinkage Decimal @default(0) @map("qty_shrinkage") @db.Decimal(18, 3) + costOpeningTotal Decimal @default(0) @map("cost_opening_total") @db.Decimal(18, 2) + costAdditionalTotal Decimal @default(0) @map("cost_additional_total") @db.Decimal(18, 2) + revenueTotal Decimal @default(0) @map("revenue_total") @db.Decimal(18, 2) + profitLossTotal Decimal @default(0) @map("profit_loss_total") @db.Decimal(18, 2) + agentSharePercent Decimal? @map("agent_share_percent") @db.Decimal(7, 2) + agentProfitTotal Decimal @default(0) @map("agent_profit_total") @db.Decimal(18, 2) + closedAt DateTime? @map("closed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([status]) + @@index([closedAt]) + @@map("purchase_realization_summaries") +} + +model LotWashing { + id BigInt @id @default(autoincrement()) + washingNo String @unique @map("washing_no") @db.VarChar(50) + lotId BigInt @map("lot_id") + lot InventoryLot @relation(fields: [lotId], references: [id], onDelete: Restrict) + washingPlaceId BigInt @map("washing_place_id") + washingPlace WashingPlace @relation(fields: [washingPlaceId], references: [id], onDelete: Restrict) + washingCost Decimal @map("washing_cost") @db.Decimal(18, 2) + durationHours Int @map("duration_hours") + receiptFileUrl String? @map("receipt_file_url") @db.VarChar(255) + status String @default("IN_PROGRESS") @db.VarChar(20) + startedAt DateTime @default(now()) @map("started_at") + expectedDoneAt DateTime @map("expected_done_at") + completedAt DateTime? @map("completed_at") + beforeQty Decimal @map("before_qty") @db.Decimal(18, 3) + afterQty Decimal? @map("after_qty") @db.Decimal(18, 3) + shrinkageQty Decimal? @map("shrinkage_qty") @db.Decimal(18, 3) + beforeGradeName String? @map("before_grade_name") @db.VarChar(100) + afterGradeName String? @map("after_grade_name") @db.VarChar(100) + beforeWarehouseName String @map("before_warehouse_name") @db.VarChar(150) + beforeLocationName String? @map("before_location_name") @db.VarChar(150) + afterWarehouseName String? @map("after_warehouse_name") @db.VarChar(150) + afterLocationName String? @map("after_location_name") @db.VarChar(150) + createdById BigInt @map("created_by") + createdBy User @relation("WashingCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + completedById BigInt? @map("completed_by") + completedBy User? @relation("WashingCompletedBy", fields: [completedById], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([lotId, status]) + @@map("lot_washings") +} + +model AppSetting { + id BigInt @id @default(autoincrement()) + singletonKey String @unique @default("SYSTEM") @map("singleton_key") @db.VarChar(30) + companyName String? @map("company_name") @db.VarChar(150) + companyEmail String? @map("company_email") @db.VarChar(150) + companyPhone String? @map("company_phone") @db.VarChar(50) + companyBankName String? @map("company_bank_name") @db.VarChar(150) + companyBankAccountNumber String? @map("company_bank_account_number") @db.VarChar(80) + companyAddress String? @map("company_address") + companyTimezone String @default("Asia/Jakarta") @map("company_timezone") @db.VarChar(80) + smtpHost String? @map("smtp_host") @db.VarChar(150) + smtpPort Int? @map("smtp_port") + smtpSecure Boolean @default(true) @map("smtp_secure") + smtpUser String? @map("smtp_user") @db.VarChar(150) + smtpPassword String? @map("smtp_password") @db.VarChar(255) + smtpFromName String? @map("smtp_from_name") @db.VarChar(150) + smtpFromEmail String? @map("smtp_from_email") @db.VarChar(150) + purchasePrefix String @default("PO") @map("purchase_prefix") @db.VarChar(20) + receiptPrefix String @default("RCV") @map("receipt_prefix") @db.VarChar(20) + lotPrefix String @default("LOT") @map("lot_prefix") @db.VarChar(20) + adjustmentPrefix String @default("STA") @map("adjustment_prefix") @db.VarChar(20) + transformationPrefix String @default("MIX") @map("transformation_prefix") @db.VarChar(20) + fundRequestPrefix String @default("FR") @map("fund_request_prefix") @db.VarChar(20) + washingPrefix String @default("WSH") @map("washing_prefix") @db.VarChar(20) + regularSalePrefix String @default("SRG") @map("regular_sale_prefix") @db.VarChar(20) + jitSalePrefix String @default("SJT") @map("jit_sale_prefix") @db.VarChar(20) + consignmentPrefix String @default("TJT") @map("consignment_prefix") @db.VarChar(20) + defaultLocale String @default("id") @map("default_locale") @db.VarChar(10) + currencyCode String @default("IDR") @map("currency_code") @db.VarChar(10) + dateFormat String @default("DD/MM/YYYY") @map("date_format") @db.VarChar(30) + passwordMinLength Int @default(8) @map("password_min_length") + sessionTimeoutMinutes Int @default(720) @map("session_timeout_minutes") + requireEmailVerification Boolean @default(true) @map("require_email_verification") + auditRetentionDays Int @default(365) @map("audit_retention_days") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + companyBankAccounts CompanyBankAccount[] + + @@map("app_settings") +} + +model CompanyBankAccount { + id BigInt @id @default(autoincrement()) + appSettingId BigInt @map("app_setting_id") + appSetting AppSetting @relation(fields: [appSettingId], references: [id], onDelete: Cascade) + bankId BigInt @map("bank_id") + bank Bank @relation(fields: [bankId], references: [id], onDelete: Restrict) + accountNumber String @map("account_number") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + fundRequests FundRequest[] + + @@unique([appSettingId, bankId, accountNumber]) + @@map("company_bank_accounts") +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..1f76a9d Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/logo_abelbirdnest.png b/public/logo_abelbirdnest.png new file mode 100755 index 0000000..1746601 Binary files /dev/null and b/public/logo_abelbirdnest.png differ diff --git a/scripts/backfill-purchase-realization.mjs b/scripts/backfill-purchase-realization.mjs new file mode 100644 index 0000000..af5521f --- /dev/null +++ b/scripts/backfill-purchase-realization.mjs @@ -0,0 +1,234 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const OPENING_EVENT_TYPES = new Set(["OPENING_COST"]); +const SALE_EVENT_TYPES = new Set(["SALE_REVENUE", "CONSIGNMENT_REVENUE"]); +const RETURN_EVENT_TYPES = new Set(["SALE_RETURN", "CONSIGNMENT_RETURN"]); +const COST_ADDITIONAL_EVENT_TYPES = new Set([ + "WASHING_COST", + "WASHING_SHRINKAGE", + "TRANSFORMATION_SHRINKAGE", + "SALE_SHRINKAGE", + "CONSIGNMENT_SHRINKAGE", + "STOCK_ADJUSTMENT_LOSS", + "MANUAL_ADJUSTMENT" +]); + +const roundQty = (value) => Number(value.toFixed(3)); +const roundAmount = (value) => Number(value.toFixed(2)); + +async function recalcSummary(purchaseId, agentSharePercent) { + const entries = await prisma.purchaseRealizationEntry.findMany({ + where: { purchaseId } + }); + + const aggregate = entries.reduce( + (state, entry) => { + const qtyIn = entry.qtyIn.toNumber(); + const qtyOut = entry.qtyOut.toNumber(); + const qtyShrinkage = entry.qtyShrinkage.toNumber(); + const amountCost = entry.amountCost.toNumber(); + const amountRevenue = entry.amountRevenue.toNumber(); + const amountExpense = entry.amountExpense.toNumber(); + const agentAmount = entry.agentAmount.toNumber(); + + state.qtyRemaining += qtyIn - qtyOut - qtyShrinkage; + state.profitLossTotal += amountRevenue - amountCost - amountExpense; + state.agentProfitTotal += agentAmount; + + if (OPENING_EVENT_TYPES.has(entry.eventType)) { + state.qtyOpening += qtyIn; + state.costOpeningTotal += amountCost; + } + if (SALE_EVENT_TYPES.has(entry.eventType)) { + state.qtySold += qtyOut; + state.revenueTotal += amountRevenue; + } + if (RETURN_EVENT_TYPES.has(entry.eventType)) { + state.qtyReturned += qtyIn; + } + if (COST_ADDITIONAL_EVENT_TYPES.has(entry.eventType)) { + state.costAdditionalTotal += amountCost + amountExpense; + } + state.qtyShrinkage += qtyShrinkage; + return state; + }, + { + qtyOpening: 0, + qtyRemaining: 0, + qtySold: 0, + qtyReturned: 0, + qtyShrinkage: 0, + costOpeningTotal: 0, + costAdditionalTotal: 0, + revenueTotal: 0, + profitLossTotal: 0, + agentProfitTotal: 0 + } + ); + + const status = + aggregate.qtyOpening <= 0 + ? "OPEN" + : aggregate.qtyRemaining <= 0 + ? "READY_TO_CLOSE" + : aggregate.qtySold > 0 || aggregate.qtyShrinkage > 0 + ? "PARTIAL" + : "OPEN"; + + await prisma.purchaseRealizationSummary.upsert({ + where: { purchaseId }, + update: { + status, + qtyOpening: roundQty(aggregate.qtyOpening), + qtyRemaining: roundQty(aggregate.qtyRemaining), + qtySold: roundQty(aggregate.qtySold), + qtyReturned: roundQty(aggregate.qtyReturned), + qtyShrinkage: roundQty(aggregate.qtyShrinkage), + costOpeningTotal: roundAmount(aggregate.costOpeningTotal), + costAdditionalTotal: roundAmount(aggregate.costAdditionalTotal), + revenueTotal: roundAmount(aggregate.revenueTotal), + profitLossTotal: roundAmount(aggregate.profitLossTotal), + agentSharePercent, + agentProfitTotal: roundAmount(aggregate.agentProfitTotal), + closedAt: null + }, + create: { + purchaseId, + status, + qtyOpening: roundQty(aggregate.qtyOpening), + qtyRemaining: roundQty(aggregate.qtyRemaining), + qtySold: roundQty(aggregate.qtySold), + qtyReturned: roundQty(aggregate.qtyReturned), + qtyShrinkage: roundQty(aggregate.qtyShrinkage), + costOpeningTotal: roundAmount(aggregate.costOpeningTotal), + costAdditionalTotal: roundAmount(aggregate.costAdditionalTotal), + revenueTotal: roundAmount(aggregate.revenueTotal), + profitLossTotal: roundAmount(aggregate.profitLossTotal), + agentSharePercent, + agentProfitTotal: roundAmount(aggregate.agentProfitTotal), + closedAt: null + } + }); +} + +async function main() { + const purchases = await prisma.purchase.findMany({ + where: { + status: "SUBMITTED", + purchaseType: { + in: ["REGULAR", "OFFICE_BUYOUT"] + } + }, + include: { + profitShareScheme: { + select: { + shareAgent: true + } + }, + lots: true + }, + orderBy: { id: "asc" } + }); + + let allocationsCreated = 0; + let entriesCreated = 0; + + for (const purchase of purchases) { + for (const lot of purchase.lots) { + const allocationCount = await prisma.lotPurchaseAllocation.count({ + where: { + lotId: lot.id, + purchaseId: purchase.id + } + }); + + if (allocationCount === 0) { + await prisma.lotPurchaseAllocation.create({ + data: { + lotId: lot.id, + purchaseId: purchase.id, + purchaseLineId: lot.purchaseLineId, + sourceType: lot.sourceType, + sourceRefId: lot.sourceRefId, + agentIdSnapshot: purchase.agentId, + profitShareSchemeIdSnapshot: purchase.profitShareSchemeId, + qtyAllocated: lot.availableQty, + costTotalAllocated: roundAmount(lot.availableQty.toNumber() * lot.unitCost.toNumber()), + unitCostSnapshot: lot.unitCost, + notes: "Backfill opening allocation" + } + }); + allocationsCreated += 1; + } + + const openingCount = await prisma.purchaseRealizationEntry.count({ + where: { + purchaseId: purchase.id, + lotId: lot.id, + eventType: "OPENING_COST" + } + }); + + if (openingCount === 0) { + const allocation = await prisma.lotPurchaseAllocation.findFirst({ + where: { + lotId: lot.id, + purchaseId: purchase.id + }, + orderBy: { id: "asc" } + }); + + await prisma.purchaseRealizationEntry.create({ + data: { + purchaseId: purchase.id, + lotId: lot.id, + allocationId: allocation?.id ?? null, + eventType: "OPENING_COST", + referenceType: "PURCHASE", + referenceId: purchase.id, + occurredAt: lot.receivedAt, + qtyIn: lot.originalQty, + qtyOut: 0, + qtyShrinkage: 0, + amountCost: roundAmount(lot.originalQty.toNumber() * lot.unitCost.toNumber()), + amountRevenue: 0, + amountExpense: 0, + amountProfit: 0, + agentSharePercentSnapshot: purchase.profitShareScheme?.shareAgent ?? null, + agentAmount: 0, + notes: "Backfill opening realization" + } + }); + entriesCreated += 1; + } + } + + await recalcSummary( + purchase.id, + purchase.profitShareScheme?.shareAgent?.toNumber() ?? null + ); + } + + console.log( + JSON.stringify( + { + purchases: purchases.length, + allocations_created: allocationsCreated, + entries_created: entriesCreated + }, + null, + 2 + ) + ); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/seed-banks-indonesia.mjs b/scripts/seed-banks-indonesia.mjs new file mode 100644 index 0000000..a10ff73 --- /dev/null +++ b/scripts/seed-banks-indonesia.mjs @@ -0,0 +1,222 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const banks = [ + ["002", "Bank Rakyat Indonesia (Persero) Tbk"], + ["008", "Bank Mandiri (Persero) Tbk"], + ["009", "Bank Negara Indonesia (Persero) Tbk"], + ["014", "Bank Central Asia Tbk"], + ["019", "Bank Panin Tbk"], + ["022", "Bank CIMB Niaga Tbk"], + ["023", "Bank UOB Indonesia"], + ["026", "Bank Lippo"], + ["028", "Bank OCBC NISP Tbk"], + ["030", "American Express Bank"], + ["031", "Citibank N.A."], + ["032", "JP. Morgan Chase Bank NA"], + ["033", "Bank of America N.A."], + ["034", "ING Indonesia Bank"], + ["036", "Bank Ekspor Indonesia"], + ["037", "Bank Artha Graha Int'l"], + ["039", "Bank Credit Agricole Indosuez"], + ["040", "The Bank of Hongkong & Shanghai B.C. (Hongkong)"], + ["041", "The Bank of Tokyo Mitsubishi UFJ"], + ["042", "Bank Sumitomo Mitsui Indonesia"], + ["045", "Bank Sumitomo Mitsui Indonesia"], + ["046", "Bank DBS Indonesia"], + ["047", "Bank Resona Perdania"], + ["048", "Bank Mizuho Indonesia"], + ["050", "Standard Chartered Bank"], + ["052", "Bank ABN Amro"], + ["053", "Bank Keppel Tatlee Buana"], + ["054", "Bank Capital Indonesia"], + ["057", "Bank BNP Paribas Indonesia"], + ["059", "Korea Exchange Bank Danamon"], + ["061", "ANZ Indonesia"], + ["067", "Deutsche Bank AG"], + ["068", "Bank Woori Indonesia"], + ["069", "Bank of China"], + ["076", "Bank Bumi Arta"], + ["089", "Bank IFI"], + ["093", "Bank Jtrust"], + ["097", "Bank Haja"], + ["110", "Bank Jabar Banten (BJB)"], + ["111", "Bank DKI"], + ["112", "Bank Pembangunan Daerah Daerah Istimewa Yogyakarta"], + ["113", "Bank Jateng"], + ["114", "Bank Jatim"], + ["115", "Bank Jambi"], + ["116", "Bank Aceh"], + ["117", "Bank Sumut"], + ["118", "Bank Nagari"], + ["119", "Bank Riau Kepri"], + ["120", "Bank Sumsel Babel"], + ["121", "Bank Lampung"], + ["122", "Bank Kalsel"], + ["123", "Bank Kaltimtara"], + ["124", "Bank Kaltim & Utara"], + ["125", "Bank Kalteng"], + ["126", "Bank Sulselbar"], + ["127", "Bank SulutGo"], + ["128", "Bank NTB Syariah"], + ["129", "Bank Bali"], + ["130", "Bank NTT"], + ["131", "Bank Maluku Malut"], + ["132", "Bank Papua"], + ["133", "Bank Bengkulu"], + ["134", "Bank Sulselbar"], + ["135", "Bank Sultra"], + ["136", "Bank Sulawesi Tenggara"], + ["137", "Bank Sulawesi Tengah"], + ["138", "Bank Sulawesi Selatan"], + ["139", "Bank Sulawesi Utara"], + ["141", "Bank Kalteng (Kaltimtara)"], + ["147", "Bank Muamalat Indonesia"], + ["145", "Bank Nusantara Parahyangan"], + ["146", "Bank of India Indonesia"], + ["151", "Bank Mestika Dharma"], + ["152", "Shinhan Bank Indonesia (Metro Express)"], + ["153", "Bank Sinarmas"], + ["157", "Bank Maspion"], + ["159", "Bank Hagakita"], + ["161", "Bank Ganesha"], + ["162", "Bank Windu Kentjana"], + ["166", "Bank ICBC Indonesia (Halim Indonesia Bank)"], + ["167", "Bank QNB Indonesia (QNB Kesawan)"], + ["171", "Bank of Tokyo Mitsubishi UFJ Indonesia"], + ["176", "Bank QNB Indonesia"], + ["186", "Bank Agris"], + ["213", "Bank Tabungan Pensiunan Nasional (BTPN)"], + ["200", "Bank Tabungan Negara (BTN)"], + ["203", "Bank Bumi Arta"], + ["213", "BTPN/Jenius BTPN"], + ["216", "Bank Artha Graha International"], + ["240", "Bank Victoria International"], + ["244", "Bank Index Selindo"], + ["245", "Bank Kesejahteraan Ekonomi"], + ["246", "Bank Harfa"], + ["247", "Bank Artos Indonesia"], + ["251", "Prima Master Bank"], + ["252", "Bank Persyarikatan Indonesia"], + ["253", "Liman International Bank"], + ["254", "Bank Dipo International (Sahabat Sampoerna)"], + ["255", "Bank Fama Internasional"], + ["256", "Bank Kesehatan Bumi Arta"], + ["257", "Bank Mayora Indonesia"], + ["258", "Bank Royal Indonesia"], + ["259", "Centratama Nasional Bank"], + ["261", "Bank Indonesia"], + ["262", "Bank Multika"], + ["263", "Bank Permata"], + ["267", "Bank Raya Indonesia"], + ["273", "Bank Nusantara Parahyangan Syariah"], + ["274", "Bank Jasa Jakarta"], + ["275", "Bank Alfindo"], + ["282", "Bank Yudha Bhakti"], + ["283", "Bank MNC"], + ["285", "Bank Bintang Manunggal"], + ["286", "Bank Haga"], + ["287", "Bank Mega"], + ["294", "Bank Bisnis Internasional"], + ["295", "Bank Sri Partha"], + ["422", "BRI Syariah (migrasi ke BSI)"], + ["423", "BCA Syariah"], + ["425", "Bank BJB Syariah"], + ["426", "Bank Mega"], + ["427", "Bank Syariah Mandiri / BNI Syariah"], + ["431", "Bank BTPN Syariah"], + ["451", "Bank Syariah Indonesia"], + ["453", "Bank BPD Kaltim"], + ["454", "Bank Jatim Syariah"], + ["456", "Bank Nusantara Parahyangan Syariah"], + ["503", "Bank Agris"], + ["506", "Bank Sinarmas Syariah"], + ["510", "Prima Master Bank"], + ["513", "Bank Ina Perdana"], + ["517", "Bank Harfa"], + ["521", "Bank Akita"], + ["526", "Liman International Bank"], + ["531", "Anglomas Internasional Bank"], + ["535", "Bank Kesejahteraan"], + ["536", "BCA Syariah"], + ["542", "Artos Indonesia Bank"], + ["547", "Bank Purba Danarta"], + ["548", "Bank Multi Arta Sentosa"], + ["550", "Bank Andara"], + ["553", "Mayora Bank"], + ["555", "Bank Victoria International"], + ["562", "Bank Fama International"], + ["564", "Bank Mandiri Taspen Pos"], + ["566", "Bank Victoria International"], + ["567", "Bank Harda"], + ["688", "BPR KS"], + ["761", "Bank Rakyat Indonesia"], + ["789", "Indosat Dompetku"], + ["811", "BPR Bank Indonesia"], + ["836", "Bank OCBC NISP"], + ["911", "Link Aja"], + ["949", "Bank CTBC (China Trust) Indonesia"], + ["950", "Bank Commonwealth"], + ["956", "Bank Merincorp"], + ["957", "Bank Diners Club"], + ["985", "The Royal Bank"], + ["988", "Bank Swaguna"], + ["989", "Bank QNB Indonesia"], + ["990", "Bank Swaguna"], + ["992", "Bank Himpunan Saudara"], + ["993", "Bank Jasa Jakarta"], + ["994", "Bank Swaguna"] +]; + +function toRecords(list) { + const byCode = new Map(); + const byName = new Set(); + + for (const [code, name] of list) { + const trimmedCode = String(code).trim().padStart(3, "0"); + const trimmedName = String(name).trim(); + if (!trimmedCode || !trimmedName) continue; + const normalizedName = trimmedName.toUpperCase(); + + if (!byCode.has(trimmedCode) && !byName.has(normalizedName)) { + byCode.set(trimmedCode, trimmedName); + byName.add(normalizedName); + } + } + return [...byCode.entries()].map(([code, name]) => ({ code, name, address: "Indonesia" })); +} + +async function main() { + const records = toRecords(banks); + let created = 0; + let updated = 0; + + for (const record of records) { + const current = await prisma.bank.findUnique({ where: { code: record.code } }); + if (current) { + await prisma.bank.update({ + where: { code: record.code }, + data: { name: record.name, address: record.address, status: "ACTIVE" } + }); + updated += 1; + } else { + await prisma.bank.create({ + data: { code: record.code, name: record.name, address: record.address, status: "ACTIVE" } + }); + created += 1; + } + } + + const total = await prisma.bank.count(); + console.log(`Seeded banks: created=${created}, updated=${updated}, total=${total}`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/seed-global-currencies.mjs b/scripts/seed-global-currencies.mjs new file mode 100644 index 0000000..56b8ea0 --- /dev/null +++ b/scripts/seed-global-currencies.mjs @@ -0,0 +1,77 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const globalCurrencies = [ + ["IDR", "Indonesian Rupiah", "Mata uang default sistem."], + ["USD", "US Dollar", "Mata uang internasional umum."], + ["EUR", "Euro", "Mata uang utama kawasan Euro."], + ["GBP", "British Pound Sterling", "Mata uang utama Inggris."], + ["JPY", "Japanese Yen", "Mata uang utama Jepang."], + ["CNY", "Chinese Yuan Renminbi", "Mata uang utama Tiongkok."], + ["HKD", "Hong Kong Dollar", "Mata uang utama Hong Kong."], + ["SGD", "Singapore Dollar", "Mata uang regional umum."], + ["AUD", "Australian Dollar", "Mata uang utama Australia."], + ["NZD", "New Zealand Dollar", "Mata uang utama Selandia Baru."], + ["CAD", "Canadian Dollar", "Mata uang utama Kanada."], + ["CHF", "Swiss Franc", "Mata uang utama Swiss."], + ["SEK", "Swedish Krona", "Mata uang utama Swedia."], + ["NOK", "Norwegian Krone", "Mata uang utama Norwegia."], + ["DKK", "Danish Krone", "Mata uang utama Denmark."], + ["PLN", "Polish Zloty", "Mata uang utama Polandia."], + ["CZK", "Czech Koruna", "Mata uang utama Ceko."], + ["HUF", "Hungarian Forint", "Mata uang utama Hungaria."], + ["AED", "UAE Dirham", "Mata uang utama Uni Emirat Arab."], + ["SAR", "Saudi Riyal", "Mata uang utama Arab Saudi."], + ["QAR", "Qatari Riyal", "Mata uang utama Qatar."], + ["KWD", "Kuwaiti Dinar", "Mata uang utama Kuwait."], + ["BHD", "Bahraini Dinar", "Mata uang utama Bahrain."], + ["OMR", "Omani Rial", "Mata uang utama Oman."], + ["INR", "Indian Rupee", "Mata uang utama India."], + ["PKR", "Pakistani Rupee", "Mata uang utama Pakistan."], + ["BDT", "Bangladeshi Taka", "Mata uang utama Bangladesh."], + ["KRW", "South Korean Won", "Mata uang utama Korea Selatan."], + ["TWD", "New Taiwan Dollar", "Mata uang utama Taiwan."], + ["THB", "Thai Baht", "Mata uang utama Thailand."], + ["MYR", "Malaysian Ringgit", "Mata uang utama Malaysia."], + ["PHP", "Philippine Peso", "Mata uang utama Filipina."], + ["VND", "Vietnamese Dong", "Mata uang utama Vietnam."], + ["KHR", "Cambodian Riel", "Mata uang utama Kamboja."], + ["LAK", "Lao Kip", "Mata uang utama Laos."], + ["MMK", "Myanmar Kyat", "Mata uang utama Myanmar."], + ["ZAR", "South African Rand", "Mata uang utama Afrika Selatan."], + ["NGN", "Nigerian Naira", "Mata uang utama Nigeria."], + ["EGP", "Egyptian Pound", "Mata uang utama Mesir."], + ["KES", "Kenyan Shilling", "Mata uang utama Kenya."], + ["TRY", "Turkish Lira", "Mata uang utama Turki."], + ["ILS", "Israeli New Shekel", "Mata uang utama Israel."], + ["MXN", "Mexican Peso", "Mata uang utama Meksiko."], + ["BRL", "Brazilian Real", "Mata uang utama Brasil."], + ["ARS", "Argentine Peso", "Mata uang utama Argentina."], + ["CLP", "Chilean Peso", "Mata uang utama Chili."], + ["COP", "Colombian Peso", "Mata uang utama Kolombia."], + ["PEN", "Peruvian Sol", "Mata uang utama Peru."] +]; + +async function main() { + const result = await prisma.currency.createMany({ + data: globalCurrencies.map(([code, name, description]) => ({ + code, + name, + description, + status: "ACTIVE" + })), + skipDuplicates: true + }); + + console.log(JSON.stringify({ inserted: result.count, total_candidates: globalCurrencies.length }, null, 2)); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/seed-grades-from-xls.mjs b/scripts/seed-grades-from-xls.mjs new file mode 100644 index 0000000..a528cf4 --- /dev/null +++ b/scripts/seed-grades-from-xls.mjs @@ -0,0 +1,67 @@ +import path from "node:path"; + +import XLSX from "xlsx"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const sourcePath = + process.argv[2] ?? "/Users/wirabasalamah/work/abelbirdnest/data gudang/Grade.xls"; + +function formatCode(prefix, sequence) { + return `${prefix}${String(sequence).padStart(5, "0")}`; +} + +async function main() { + const workbook = XLSX.readFile(sourcePath); + const sheetName = workbook.SheetNames[0]; + const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" }); + + let mangkokSequence = 1; + let nonMangkokSequence = 1; + + for (const row of rows) { + const legacyCode = String(row.Code || "").trim(); + const name = String(row.Name || "").trim(); + const description = String(row.Description || "").trim(); + const isMangkok = String(row.Mangkok || "").trim().toLowerCase() === "yes"; + + if (!name) continue; + + const prefix = isMangkok ? "MGK" : "GRD"; + const code = isMangkok + ? formatCode(prefix, mangkokSequence++) + : formatCode(prefix, nonMangkokSequence++); + + await prisma.grade.upsert({ + where: { legacyCode: legacyCode || "__missing__" }, + update: { + code, + isMangkok, + name, + description: description || null, + status: "ACTIVE" + }, + create: { + code, + legacyCode: legacyCode || null, + isMangkok, + name, + description: description || null, + status: "ACTIVE" + } + }); + } + + const count = await prisma.grade.count(); + console.log(`Seeded grades: ${count} from ${path.basename(sourcePath)}`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/adjustment-reasons/page.tsx b/src/app/adjustment-reasons/page.tsx new file mode 100644 index 0000000..9ff46a0 --- /dev/null +++ b/src/app/adjustment-reasons/page.tsx @@ -0,0 +1,14 @@ +import { AdjustmentReasonsClient } from "@/components/master-data/adjustment-reasons-client"; +import { AppShell } from "@/components/layout/app-shell"; + +export default async function AdjustmentReasonsPage() { + return ( + + + + ); +} diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx new file mode 100644 index 0000000..1a2476c --- /dev/null +++ b/src/app/agents/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { AgentsClient } from "@/components/master-data/agents-client"; + +export default async function AgentsPage() { + return ( + + + + ); +} diff --git a/src/app/api/v1/adjustment-reasons/[id]/route.ts b/src/app/api/v1/adjustment-reasons/[id]/route.ts new file mode 100644 index 0000000..64cab5a --- /dev/null +++ b/src/app/api/v1/adjustment-reasons/[id]/route.ts @@ -0,0 +1,138 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeAdjustmentReason } from "@/features/adjustment-reasons/lib/serialize-adjustment-reason"; +import { adjustmentReasonInputSchema } from "@/features/adjustment-reasons/schemas/adjustment-reason.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const reason = await prisma.adjustmentReason.findUnique({ where: { id: parsedId } }); + if (!reason) return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 }); + return NextResponse.json({ data: serializeAdjustmentReason(reason) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const parsed = adjustmentReasonInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + try { + const existing = await prisma.adjustmentReason.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 }); + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "ADJ", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => + prisma.adjustmentReason.count({ where: { code: { startsWith: "ADJ" } } }), + exists: async (code) => + (await prisma.adjustmentReason.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + const reason = await prisma.adjustmentReason.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + category: parsed.data.category, + status: parsed.data.status + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "ADJUSTMENT_REASON_UPDATED", + entityType: "ADJUSTMENT_REASON", + entityId: reason.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Adjustment reason ${reason.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + category: existing.category, + status: existing.status + }, + { + code: reason.code, + name: reason.name, + category: reason.category, + status: reason.status + } + ) + }); + return NextResponse.json({ data: serializeAdjustmentReason(reason) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode alasan sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + try { + const existing = await prisma.adjustmentReason.findUnique({ where: { id: parsedId } }); + await prisma.adjustmentReason.delete({ where: { id: parsedId } }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "ADJUSTMENT_REASON_DELETED", + entityType: "ADJUSTMENT_REASON", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Adjustment reason ${existing?.code ?? parsedId.toString()} dihapus` + }); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/adjustment-reasons/route.ts b/src/app/api/v1/adjustment-reasons/route.ts new file mode 100644 index 0000000..e199b72 --- /dev/null +++ b/src/app/api/v1/adjustment-reasons/route.ts @@ -0,0 +1,72 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeAdjustmentReason } from "@/features/adjustment-reasons/lib/serialize-adjustment-reason"; +import { adjustmentReasonInputSchema } from "@/features/adjustment-reasons/schemas/adjustment-reason.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const data = await prisma.adjustmentReason.findMany({ orderBy: [{ createdAt: "desc" }] }); + return NextResponse.json({ data: data.map(serializeAdjustmentReason) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const parsed = adjustmentReasonInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "ADJ", + requestedCode: parsed.data.code, + countExisting: () => + prisma.adjustmentReason.count({ where: { code: { startsWith: "ADJ" } } }), + exists: async (code) => + (await prisma.adjustmentReason.count({ where: { code } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + const reason = await prisma.adjustmentReason.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + category: parsed.data.category, + status: parsed.data.status + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "ADJUSTMENT_REASON_CREATED", + entityType: "ADJUSTMENT_REASON", + entityId: reason.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Adjustment reason ${reason.code} dibuat` + }); + return NextResponse.json({ data: serializeAdjustmentReason(reason) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode alasan sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/agents/[id]/detail/route.ts b/src/app/api/v1/agents/[id]/detail/route.ts new file mode 100644 index 0000000..6da00b9 --- /dev/null +++ b/src/app/api/v1/agents/[id]/detail/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; + +import { serializeAgent } from "@/features/agents/lib/serialize-agent"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseId(id: string) { + try { + return BigInt(id); + } catch { + return null; + } +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const agent = await prisma.agent.findUnique({ + where: { id: parsedId }, + include: { + profitShareScheme: true, + bankAccounts: { include: { bank: true } }, + _count: { + select: { + purchases: true + } + } + } + }); + + if (!agent) { + return NextResponse.json({ message: "Agent not found" }, { status: 404 }); + } + + const [mutations, consignmentCount, regularSaleCount] = await Promise.all([ + prisma.agentBalanceMutation.findMany({ + where: { agentId: parsedId }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }] + }), + prisma.consignmentLine.count({ + where: { + status: "CLOSED", + agentCommission: { gt: 0 }, + lot: { + purchase: { + agentId: parsedId + } + } + } + }), + prisma.regularSaleLine.findMany({ + where: { + agentId: parsedId, + regularSale: { + status: "CLOSED" + } + }, + select: { + regularSaleId: true + }, + distinct: ["regularSaleId"] + }) + ]); + + return NextResponse.json({ + data: { + ...serializeAgent(agent), + stats: { + purchase_count: agent._count.purchases, + consignment_close_count: consignmentCount, + regular_sale_close_count: regularSaleCount.length, + history_count: mutations.length + }, + balance_history: mutations.map((item) => ({ + id: item.id.toString(), + balance_type: item.balanceType as "PROFIT_SHARE" | "CAPITAL", + source: item.source as + | "OPENING_BALANCE" + | "MANUAL_ADJUSTMENT" + | "CONSIGNMENT_COMMISSION" + | "REGULAR_SALE_COMMISSION" + | "JIT_SALE_COMMISSION" + | "OFFICE_BUYOUT_COMMISSION" + | "FUND_REQUEST_PROFIT_SHARE" + | "FUND_REQUEST_CAPITAL", + direction: item.direction as "IN" | "OUT", + amount: item.amount.toNumber(), + balance_after: item.balanceAfter.toNumber(), + occurred_at: item.occurredAt.toISOString(), + effective_date: item.effectiveDate?.toISOString().slice(0, 10) ?? null, + reference_no: item.referenceNo, + description: + item.notes ?? + (item.source === "CONSIGNMENT_COMMISSION" + ? "Komisi agen dari titip jual" + : item.source === "REGULAR_SALE_COMMISSION" + ? "Komisi agen dari penjualan reguler" + : item.source === "JIT_SALE_COMMISSION" + ? "Komisi agen dari penjualan just in time" + : item.source === "OFFICE_BUYOUT_COMMISSION" + ? "Komisi agen dari pembelian kantor / buyout" + : item.source === "FUND_REQUEST_PROFIT_SHARE" + ? "Transfer dana bagi hasil ke agen" + : item.source === "FUND_REQUEST_CAPITAL" + ? "Transfer dana modal ke agen" + : item.source === "MANUAL_ADJUSTMENT" + ? "Penyesuaian manual saldo agen" + : "Saldo pembuka agen"), + notes: item.referenceType + ? `${item.referenceType}${item.referenceId ? ` · ${item.referenceId}` : ""}` + : null + })) + } + }); +} diff --git a/src/app/api/v1/agents/[id]/route.ts b/src/app/api/v1/agents/[id]/route.ts new file mode 100644 index 0000000..3e31da1 --- /dev/null +++ b/src/app/api/v1/agents/[id]/route.ts @@ -0,0 +1,274 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { + AGENT_BALANCE_DIRECTIONS, + AGENT_BALANCE_SOURCES, + AGENT_BALANCE_TYPES, + createAgentBalanceMutation +} from "@/features/agents/lib/balance-mutations"; +import { serializeAgent } from "@/features/agents/lib/serialize-agent"; +import { agentInputSchema } from "@/features/agents/schemas/agent.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const agent = await prisma.agent.findUnique({ + where: { id: parsedId }, + include: { + profitShareScheme: true, + bankAccounts: { include: { bank: true } } + } + }); + + if (!agent) return NextResponse.json({ message: "Agent not found" }, { status: 404 }); + return NextResponse.json({ data: serializeAgent(agent) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = agentInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.agent.findUnique({ + where: { id: parsedId }, + include: { + profitShareScheme: true, + bankAccounts: { include: { bank: true } } + } + }); + + if (!existing) return NextResponse.json({ message: "Agent not found" }, { status: 404 }); + + const profitShareSchemeId = BigInt(parsed.data.profit_share_scheme_id); + const bankIds = parsed.data.bank_accounts.map((account) => BigInt(account.bank_id)); + + const [scheme, banks] = await Promise.all([ + prisma.profitShareScheme.findFirst({ + where: { id: profitShareSchemeId, status: "ACTIVE" } + }), + prisma.bank.findMany({ + where: { id: { in: bankIds }, status: "ACTIVE" } + }) + ]); + + if (!scheme) { + return NextResponse.json( + { message: "Validasi gagal", errors: { profit_share_scheme_id: ["Skema bagi hasil harus dipilih dari master aktif"] } }, + { status: 400 } + ); + } + + if (banks.length !== bankIds.length) { + return NextResponse.json( + { message: "Validasi gagal", errors: { bank_accounts: ["Semua rekening harus memilih bank aktif dari master"] } }, + { status: 400 } + ); + } + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "AGT", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => prisma.agent.count({ where: { code: { startsWith: "AGT" } } }), + exists: async (code) => (await prisma.agent.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const adjustmentDate = new Date(); + const nextProfitShareBalance = parsed.data.profit_share_balance; + const nextCapitalBalance = parsed.data.capital_balance; + const previousProfitShareBalance = Number(existing.currentBalance); + const previousCapitalBalance = Number(existing.capitalBalance); + const agent = await prisma.$transaction(async (tx) => { + const updated = await tx.agent.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + identityType: parsed.data.identity_type, + identityNumber: parsed.data.identity_number, + mobilePhone: parsed.data.mobile_phone || null, + email: parsed.data.email || null, + address: parsed.data.address || null, + notes: parsed.data.notes || null, + joinDate: new Date(parsed.data.join_date), + profitShareSchemeId, + currentBalance: nextProfitShareBalance, + capitalBalance: nextCapitalBalance, + bankAccounts: { + deleteMany: {}, + create: parsed.data.bank_accounts.map((account) => ({ + bankId: BigInt(account.bank_id), + accountNumber: account.account_number + })) + } + }, + include: { + profitShareScheme: true, + bankAccounts: { include: { bank: true } } + } + }); + + const profitShareDelta = Number((nextProfitShareBalance - previousProfitShareBalance).toFixed(2)); + if (profitShareDelta !== 0) { + await createAgentBalanceMutation(tx, { + agentId: updated.id, + balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE, + direction: profitShareDelta > 0 ? AGENT_BALANCE_DIRECTIONS.IN : AGENT_BALANCE_DIRECTIONS.OUT, + source: AGENT_BALANCE_SOURCES.MANUAL_ADJUSTMENT, + amount: Math.abs(profitShareDelta), + balanceAfter: nextProfitShareBalance, + effectiveDate: adjustmentDate, + notes: "Penyesuaian manual saldo bagi hasil dari master agent" + }); + } + + const capitalDelta = Number((nextCapitalBalance - previousCapitalBalance).toFixed(2)); + if (capitalDelta !== 0) { + await createAgentBalanceMutation(tx, { + agentId: updated.id, + balanceType: AGENT_BALANCE_TYPES.CAPITAL, + direction: capitalDelta > 0 ? AGENT_BALANCE_DIRECTIONS.IN : AGENT_BALANCE_DIRECTIONS.OUT, + source: AGENT_BALANCE_SOURCES.MANUAL_ADJUSTMENT, + amount: Math.abs(capitalDelta), + balanceAfter: nextCapitalBalance, + effectiveDate: adjustmentDate, + notes: "Penyesuaian manual saldo modal dari master agent" + }); + } + + return updated; + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "AGENT_UPDATED", + entityType: "AGENT", + entityId: agent.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Agent ${agent.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + identity_type: existing.identityType, + identity_number: existing.identityNumber, + mobile_phone: existing.mobilePhone, + email: existing.email, + address: existing.address, + notes: existing.notes, + join_date: existing.joinDate.toISOString().slice(0, 10), + profit_share_scheme_id: existing.profitShareScheme.id.toString(), + profit_share_balance: Number(existing.currentBalance), + capital_balance: Number(existing.capitalBalance), + bank_accounts: existing.bankAccounts.map((account) => ({ + bank_id: account.bank.id.toString(), + account_number: account.accountNumber + })) + }, + { + code: agent.code, + name: agent.name, + identity_type: agent.identityType, + identity_number: agent.identityNumber, + mobile_phone: agent.mobilePhone, + email: agent.email, + address: agent.address, + notes: agent.notes, + join_date: agent.joinDate.toISOString().slice(0, 10), + profit_share_scheme_id: agent.profitShareScheme.id.toString(), + profit_share_balance: Number(agent.currentBalance), + capital_balance: Number(agent.capitalBalance), + bank_accounts: agent.bankAccounts.map((account) => ({ + bank_id: account.bank.id.toString(), + account_number: account.accountNumber + })) + } + ) + }); + + return NextResponse.json({ data: serializeAgent(agent) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Agent not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { identity_number: ["Kode agen atau identitas sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.agent.findUnique({ where: { id: parsedId } }); + await prisma.agent.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "AGENT_DELETED", + entityType: "AGENT", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Agent ${existing?.code ?? parsedId.toString()} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Agent not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/agents/route.ts b/src/app/api/v1/agents/route.ts new file mode 100644 index 0000000..70aa7dd --- /dev/null +++ b/src/app/api/v1/agents/route.ts @@ -0,0 +1,169 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { + AGENT_BALANCE_DIRECTIONS, + AGENT_BALANCE_SOURCES, + AGENT_BALANCE_TYPES, + createAgentBalanceMutation +} from "@/features/agents/lib/balance-mutations"; +import { serializeAgent } from "@/features/agents/lib/serialize-agent"; +import { agentInputSchema } from "@/features/agents/schemas/agent.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const data = await prisma.agent.findMany({ + include: { + profitShareScheme: true, + bankAccounts: { + include: { + bank: true + } + } + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ data: data.map(serializeAgent) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = agentInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const profitShareSchemeId = BigInt(parsed.data.profit_share_scheme_id); + const bankIds = parsed.data.bank_accounts.map((account) => BigInt(account.bank_id)); + + const [scheme, banks] = await Promise.all([ + prisma.profitShareScheme.findFirst({ + where: { id: profitShareSchemeId, status: "ACTIVE" } + }), + prisma.bank.findMany({ + where: { id: { in: bankIds }, status: "ACTIVE" } + }) + ]); + + if (!scheme) { + return NextResponse.json( + { message: "Validasi gagal", errors: { profit_share_scheme_id: ["Skema bagi hasil harus dipilih dari master aktif"] } }, + { status: 400 } + ); + } + + if (banks.length !== bankIds.length) { + return NextResponse.json( + { message: "Validasi gagal", errors: { bank_accounts: ["Semua rekening harus memilih bank aktif dari master"] } }, + { status: 400 } + ); + } + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "AGT", + requestedCode: parsed.data.code, + countExisting: () => prisma.agent.count({ where: { code: { startsWith: "AGT" } } }), + exists: async (code) => (await prisma.agent.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const joinDate = new Date(parsed.data.join_date); + const agent = await prisma.$transaction(async (tx) => { + const created = await tx.agent.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + identityType: parsed.data.identity_type, + identityNumber: parsed.data.identity_number, + mobilePhone: parsed.data.mobile_phone || null, + email: parsed.data.email || null, + address: parsed.data.address || null, + notes: parsed.data.notes || null, + joinDate, + profitShareSchemeId, + currentBalance: parsed.data.profit_share_balance, + capitalBalance: parsed.data.capital_balance, + bankAccounts: { + create: parsed.data.bank_accounts.map((account) => ({ + bankId: BigInt(account.bank_id), + accountNumber: account.account_number + })) + } + }, + include: { + profitShareScheme: true, + bankAccounts: { include: { bank: true } } + } + }); + + if (parsed.data.profit_share_balance > 0) { + await createAgentBalanceMutation(tx, { + agentId: created.id, + balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE, + direction: AGENT_BALANCE_DIRECTIONS.IN, + source: AGENT_BALANCE_SOURCES.OPENING_BALANCE, + amount: parsed.data.profit_share_balance, + balanceAfter: parsed.data.profit_share_balance, + effectiveDate: joinDate, + notes: "Saldo bagi hasil awal agent" + }); + } + + if (parsed.data.capital_balance > 0) { + await createAgentBalanceMutation(tx, { + agentId: created.id, + balanceType: AGENT_BALANCE_TYPES.CAPITAL, + direction: AGENT_BALANCE_DIRECTIONS.IN, + source: AGENT_BALANCE_SOURCES.OPENING_BALANCE, + amount: parsed.data.capital_balance, + balanceAfter: parsed.data.capital_balance, + effectiveDate: joinDate, + notes: "Saldo modal awal agent" + }); + } + + return created; + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "AGENT_CREATED", + entityType: "AGENT", + entityId: agent.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Agent ${agent.code} dibuat` + }); + + return NextResponse.json({ data: serializeAgent(agent) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { identity_number: ["Kode agen atau identitas sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/audit-trail/export/route.ts b/src/app/api/v1/audit-trail/export/route.ts new file mode 100644 index 0000000..6f2274c --- /dev/null +++ b/src/app/api/v1/audit-trail/export/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import * as XLSX from "xlsx"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseDateStart(value: string | null) { + if (!value) return null; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function parseDateEnd(value: string | null) { + if (!value) return null; + const date = new Date(`${value}T23:59:59.999Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function buildWhere(searchParams: URLSearchParams): Prisma.AuditTrailWhereInput { + const action = searchParams.get("action")?.trim(); + const userId = searchParams.get("user_id")?.trim(); + const dateFrom = parseDateStart(searchParams.get("date_from")); + const dateTo = parseDateEnd(searchParams.get("date_to")); + const search = searchParams.get("search")?.trim(); + + const where: Prisma.AuditTrailWhereInput = {}; + + if (action) { + where.action = action; + } + + if (userId) { + try { + where.userId = BigInt(userId); + } catch { + where.userId = BigInt(-1); + } + } + + if (dateFrom || dateTo) { + where.occurredAt = { + ...(dateFrom ? { gte: dateFrom } : {}), + ...(dateTo ? { lte: dateTo } : {}) + }; + } + + if (search) { + where.OR = [ + { action: { contains: search, mode: "insensitive" } }, + { entityType: { contains: search, mode: "insensitive" } }, + { entityId: { contains: search, mode: "insensitive" } }, + { pathname: { contains: search, mode: "insensitive" } }, + { summary: { contains: search, mode: "insensitive" } }, + { user: { name: { contains: search, mode: "insensitive" } } }, + { user: { email: { contains: search, mode: "insensitive" } } }, + { user: { username: { contains: search, mode: "insensitive" } } } + ]; + } + + return where; +} + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { searchParams } = new URL(request.url); + const where = buildWhere(searchParams); + + const items = await prisma.auditTrail.findMany({ + where, + include: { + user: { + include: { + role: { select: { code: true } } + } + } + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }] + }); + + const rows = items.map((item) => ({ + "Occurred At": item.occurredAt.toISOString(), + Action: item.action, + "Entity Type": item.entityType, + "Entity ID": item.entityId ?? "", + Method: item.method, + Pathname: item.pathname, + "Status Code": item.statusCode, + Summary: item.summary ?? "", + User: item.user?.name ?? "", + Email: item.user?.email ?? "", + Username: item.user?.username ?? "", + Role: item.user?.role.code ?? "", + Metadata: item.metadata ? JSON.stringify(item.metadata) : "" + })); + + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.json_to_sheet(rows); + XLSX.utils.book_append_sheet(workbook, worksheet, "Audit Trail"); + const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }); + + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename=\"audit-trail-${new Date() + .toISOString() + .slice(0, 10)}.xlsx\"` + } + }); +} diff --git a/src/app/api/v1/audit-trail/route.ts b/src/app/api/v1/audit-trail/route.ts new file mode 100644 index 0000000..33cdb3a --- /dev/null +++ b/src/app/api/v1/audit-trail/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; + +import { serializeAuditTrail } from "@/features/audit-trail/lib/serialize-audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseDateStart(value: string | null) { + if (!value) return null; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function parseDateEnd(value: string | null) { + if (!value) return null; + const date = new Date(`${value}T23:59:59.999Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function buildWhere(searchParams: URLSearchParams): Prisma.AuditTrailWhereInput { + const action = searchParams.get("action")?.trim(); + const userId = searchParams.get("user_id")?.trim(); + const dateFrom = parseDateStart(searchParams.get("date_from")); + const dateTo = parseDateEnd(searchParams.get("date_to")); + const search = searchParams.get("search")?.trim(); + + const where: Prisma.AuditTrailWhereInput = {}; + + if (action) { + where.action = action; + } + + if (userId) { + try { + where.userId = BigInt(userId); + } catch { + where.userId = BigInt(-1); + } + } + + if (dateFrom || dateTo) { + where.occurredAt = { + ...(dateFrom ? { gte: dateFrom } : {}), + ...(dateTo ? { lte: dateTo } : {}) + }; + } + + if (search) { + where.OR = [ + { action: { contains: search, mode: "insensitive" } }, + { entityType: { contains: search, mode: "insensitive" } }, + { entityId: { contains: search, mode: "insensitive" } }, + { pathname: { contains: search, mode: "insensitive" } }, + { summary: { contains: search, mode: "insensitive" } }, + { user: { name: { contains: search, mode: "insensitive" } } }, + { user: { email: { contains: search, mode: "insensitive" } } }, + { user: { username: { contains: search, mode: "insensitive" } } } + ]; + } + + return where; +} + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { searchParams } = new URL(request.url); + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); + const perPage = Math.min(100, Math.max(10, Number(searchParams.get("per_page") ?? "25") || 25)); + const skip = (page - 1) * perPage; + const where = buildWhere(searchParams); + + const [items, total, actionRows] = await Promise.all([ + prisma.auditTrail.findMany({ + where, + include: { + user: { + include: { + role: { select: { code: true } } + } + } + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + skip, + take: perPage + }), + prisma.auditTrail.count({ where }), + prisma.auditTrail.findMany({ + distinct: ["action"], + select: { action: true }, + orderBy: { action: "asc" } + }) + ]); + + return NextResponse.json({ + data: items.map(serializeAuditTrail), + meta: { + page, + per_page: perPage, + total, + total_pages: Math.max(1, Math.ceil(total / perPage)) + }, + filters: { + actions: actionRows.map((item) => item.action) + } + }); +} diff --git a/src/app/api/v1/auth/change-password/route.ts b/src/app/api/v1/auth/change-password/route.ts new file mode 100644 index 0000000..b4de53e --- /dev/null +++ b/src/app/api/v1/auth/change-password/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { hashPassword, verifyPassword } from "@/lib/auth"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +const changePasswordSchema = z + .object({ + current_password: z.string().min(1, "Password saat ini wajib diisi"), + password: z.string().min(8, "Password baru minimal 8 karakter"), + password_confirmation: z.string().min(8, "Konfirmasi password minimal 8 karakter") + }) + .refine((value) => value.password === value.password_confirmation, { + message: "Konfirmasi password tidak sama", + path: ["password_confirmation"] + }); + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + const body = await request.json(); + const parsed = changePasswordSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const user = await prisma.user.findUnique({ + where: { id: BigInt(auth.user.id) } + }); + + if (!user || !user.passwordHash) { + return NextResponse.json({ message: "User tidak ditemukan" }, { status: 404 }); + } + + if (!verifyPassword(parsed.data.current_password, user.passwordHash)) { + return NextResponse.json( + { + message: "Password saat ini tidak valid" + }, + { status: 422 } + ); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash: hashPassword(parsed.data.password) + } + }); + + await createAuditTrailSafe({ + userId: user.id, + action: "PASSWORD_CHANGED", + entityType: "AUTH", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "User mengganti password akun sendiri" + }); + + return NextResponse.json({ + message: "Password berhasil diperbarui" + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal mengganti password" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/auth/forgot-password/route.ts b/src/app/api/v1/auth/forgot-password/route.ts new file mode 100644 index 0000000..8be7091 --- /dev/null +++ b/src/app/api/v1/auth/forgot-password/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { sendPasswordResetEmail } from "@/features/auth/lib/auth-emails"; +import { buildPasswordResetUrl, issuePasswordResetToken } from "@/features/auth/lib/password-reset"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; + +const forgotPasswordSchema = z.object({ + email: z.string().trim().email("Email tidak valid") +}); + +export async function POST(request: Request) { + try { + const parsed = forgotPasswordSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const email = parsed.data.email.toLowerCase(); + const user = await prisma.user.findFirst({ + where: { + email, + status: "ACTIVE", + emailVerifiedAt: { + not: null + } + } + }); + + if (user?.email) { + const { plainToken } = await issuePasswordResetToken(user.id); + await sendPasswordResetEmail({ + to: user.email, + name: user.name, + resetUrl: buildPasswordResetUrl(plainToken) + }); + + await createAuditTrailSafe({ + userId: user.id, + action: "PASSWORD_RESET_REQUESTED", + entityType: "AUTH", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "Permintaan reset password dikirim" + }); + } + + return NextResponse.json({ + message: "Jika email terdaftar, link reset password sudah dikirim." + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal mengirim email reset password" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/auth/login/route.ts b/src/app/api/v1/auth/login/route.ts new file mode 100644 index 0000000..0f4cb49 --- /dev/null +++ b/src/app/api/v1/auth/login/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { getDefaultPathForRole } from "@/config/access-control"; +import { ensureAuthBootstrap } from "@/features/auth/lib/bootstrap-auth"; +import { + getSessionTimeoutSeconds, + isEmailVerificationRequired +} from "@/lib/app-settings"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { + AUTH_COOKIE_NAME, + createSessionToken, + getSessionTtlSeconds, + getSessionCookieOptions, + verifyPassword +} from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +const loginSchema = z.object({ + identity: z.string().trim().min(1, "Email atau username wajib diisi"), + password: z.string().min(1, "Password wajib diisi") +}); + +export async function POST(request: Request) { + try { + await ensureAuthBootstrap(); + + const body = await request.json(); + const parsed = loginSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const { identity, password } = parsed.data; + const normalizedIdentity = identity.toLowerCase(); + const [mustVerifyEmail, sessionTtlSeconds] = await Promise.all([ + isEmailVerificationRequired(), + getSessionTimeoutSeconds() + ]); + + const user = await prisma.user.findFirst({ + where: { + status: "ACTIVE", + OR: [{ email: normalizedIdentity }, { username: normalizedIdentity }] + }, + include: { + role: true + } + }); + + if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) { + await createAuditTrailSafe({ + action: "LOGIN_FAILED", + entityType: "AUTH", + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 401, + summary: "Login gagal", + metadata: { identity: normalizedIdentity } + }); + return NextResponse.json( + { + message: "Email/username atau password tidak valid" + }, + { status: 401 } + ); + } + + if (mustVerifyEmail && !user.emailVerifiedAt) { + await createAuditTrailSafe({ + userId: user.id, + action: "LOGIN_BLOCKED_UNVERIFIED", + entityType: "AUTH", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 403, + summary: "Login ditolak karena email belum diverifikasi" + }); + return NextResponse.json( + { + message: "Email belum diverifikasi. Cek inbox Anda atau kirim ulang verifikasi." + }, + { status: 403 } + ); + } + + const sessionUser = { + id: user.id.toString(), + name: user.name, + role: user.role.code, + email: user.email, + username: user.username + }; + const sessionToken = createSessionToken(sessionUser, sessionTtlSeconds); + + const response = NextResponse.json({ + message: "Login berhasil", + data: { + user: sessionUser, + redirect_to: getDefaultPathForRole(sessionUser.role), + session_token: sessionToken, + token_type: "Bearer", + expires_in: sessionTtlSeconds || getSessionTtlSeconds() + } + }); + + response.cookies.set( + AUTH_COOKIE_NAME, + sessionToken, + getSessionCookieOptions(sessionTtlSeconds) + ); + + await createAuditTrailSafe({ + userId: user.id, + action: "LOGIN_SUCCESS", + entityType: "AUTH", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "Login berhasil" + }); + + return response; + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal login" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/auth/logout/route.ts b/src/app/api/v1/auth/logout/route.ts new file mode 100644 index 0000000..ffb428d --- /dev/null +++ b/src/app/api/v1/auth/logout/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; + +import { AUTH_COOKIE_NAME, getSessionCookieOptions } from "@/lib/auth"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + const response = NextResponse.json({ + message: "Logout berhasil" + }); + + response.cookies.set(AUTH_COOKIE_NAME, "", { + ...getSessionCookieOptions(), + maxAge: 0 + }); + + await createAuditTrailSafe({ + userId: auth.ok ? auth.user.id : null, + action: "LOGOUT", + entityType: "AUTH", + entityId: auth.ok ? auth.user.id : null, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "Logout berhasil" + }); + + return response; +} diff --git a/src/app/api/v1/auth/me/route.ts b/src/app/api/v1/auth/me/route.ts new file mode 100644 index 0000000..8e87aac --- /dev/null +++ b/src/app/api/v1/auth/me/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +import { getSessionUser } from "@/lib/auth-server"; + +export async function GET() { + const user = await getSessionUser(); + + if (!user) { + return NextResponse.json( + { + message: "Unauthorized" + }, + { status: 401 } + ); + } + + return NextResponse.json({ + data: { + user + } + }); +} diff --git a/src/app/api/v1/auth/resend-verification/route.ts b/src/app/api/v1/auth/resend-verification/route.ts new file mode 100644 index 0000000..17720ff --- /dev/null +++ b/src/app/api/v1/auth/resend-verification/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { sendEmailVerificationEmail } from "@/features/auth/lib/auth-emails"; +import { + buildEmailVerificationUrl, + issueEmailVerificationToken +} from "@/features/auth/lib/email-verification"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; + +const resendVerificationSchema = z.object({ + email: z.string().trim().email("Email tidak valid") +}); + +export async function POST(request: Request) { + try { + const parsed = resendVerificationSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const email = parsed.data.email.toLowerCase(); + const user = await prisma.user.findFirst({ + where: { + email, + status: "ACTIVE", + emailVerifiedAt: null + } + }); + + if (user?.email) { + const { plainToken } = await issueEmailVerificationToken(user.id); + await sendEmailVerificationEmail({ + to: user.email, + name: user.name, + verifyUrl: buildEmailVerificationUrl(plainToken) + }); + + await createAuditTrailSafe({ + userId: user.id, + action: "EMAIL_VERIFICATION_RESENT", + entityType: "AUTH", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "Kirim ulang email verifikasi" + }); + } + + return NextResponse.json({ + message: "Jika email membutuhkan verifikasi, link verifikasi sudah dikirim." + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal mengirim email verifikasi" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/auth/reset-password/route.ts b/src/app/api/v1/auth/reset-password/route.ts new file mode 100644 index 0000000..bc69333 --- /dev/null +++ b/src/app/api/v1/auth/reset-password/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + consumePasswordResetToken +} from "@/features/auth/lib/password-reset"; +import { hashPassword } from "@/lib/auth"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; + +const resetPasswordSchema = z + .object({ + token: z.string().min(1, "Token wajib diisi"), + password: z.string().min(8, "Password minimal 8 karakter"), + password_confirmation: z.string().min(8, "Konfirmasi password minimal 8 karakter") + }) + .refine((value) => value.password === value.password_confirmation, { + message: "Konfirmasi password tidak sama", + path: ["password_confirmation"] + }); + +export async function POST(request: Request) { + try { + const parsed = resetPasswordSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const resetToken = await consumePasswordResetToken(parsed.data.token); + if (!resetToken) { + return NextResponse.json( + { + message: "Token reset password tidak valid atau sudah kedaluwarsa." + }, + { status: 400 } + ); + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: resetToken.userId }, + data: { + passwordHash: hashPassword(parsed.data.password) + } + }); + + await tx.passwordResetToken.update({ + where: { id: resetToken.id }, + data: { + usedAt: new Date() + } + }); + }); + + await createAuditTrailSafe({ + userId: resetToken.userId, + action: "PASSWORD_RESET_COMPLETED", + entityType: "AUTH", + entityId: resetToken.userId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "Password berhasil direset" + }); + + return NextResponse.json({ + message: "Password berhasil direset. Silakan login kembali." + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal reset password" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/auth/verify-email/route.ts b/src/app/api/v1/auth/verify-email/route.ts new file mode 100644 index 0000000..7619d7e --- /dev/null +++ b/src/app/api/v1/auth/verify-email/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + consumeEmailVerificationToken +} from "@/features/auth/lib/email-verification"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; + +const verifyEmailSchema = z.object({ + token: z.string().min(1, "Token wajib diisi") +}); + +export async function POST(request: Request) { + try { + const parsed = verifyEmailSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const verificationToken = await consumeEmailVerificationToken(parsed.data.token); + if (!verificationToken) { + return NextResponse.json( + { + message: "Token verifikasi tidak valid atau sudah kedaluwarsa." + }, + { status: 400 } + ); + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: verificationToken.userId }, + data: { + emailVerifiedAt: new Date() + } + }); + + await tx.emailVerificationToken.update({ + where: { id: verificationToken.id }, + data: { + usedAt: new Date() + } + }); + }); + + await createAuditTrailSafe({ + userId: verificationToken.userId, + action: "EMAIL_VERIFIED", + entityType: "AUTH", + entityId: verificationToken.userId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "Email berhasil diverifikasi" + }); + + return NextResponse.json({ + message: "Email berhasil diverifikasi. Silakan login." + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal verifikasi email" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/banks/[id]/route.ts b/src/app/api/v1/banks/[id]/route.ts new file mode 100644 index 0000000..e9865d8 --- /dev/null +++ b/src/app/api/v1/banks/[id]/route.ts @@ -0,0 +1,161 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeBank } from "@/features/banks/lib/serialize-bank"; +import { bankInputSchema } from "@/features/banks/schemas/bank.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseId(rawId: string) { + try { + return BigInt(rawId); + } catch { + return null; + } +} + +async function countBankUsage(bankName: string) { + const customerCount = await prisma.buyer.count({ where: { bankName } }); + + return customerCount; +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const bank = await prisma.bank.findUnique({ where: { id: parsedId } }); + if (!bank) return NextResponse.json({ message: "Bank not found" }, { status: 404 }); + + return NextResponse.json({ data: serializeBank(bank) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = bankInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.bank.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Bank not found" }, { status: 404 }); + + const usageCount = await countBankUsage(existing.name); + if (usageCount > 0 && existing.name !== parsed.data.name) { + return NextResponse.json( + { message: "Nama bank sedang dipakai di buyer dan tidak bisa diubah." }, + { status: 409 } + ); + } + + const bank = await prisma.bank.update({ + where: { id: parsedId }, + data: { + code: parsed.data.code, + name: parsed.data.name, + address: parsed.data.address || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "BANK_UPDATED", + entityType: "BANK", + entityId: bank.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Bank ${bank.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + address: existing.address, + status: existing.status + }, + { + code: bank.code, + name: bank.name, + address: bank.address, + status: bank.status + } + ) + }); + + return NextResponse.json({ data: serializeBank(bank) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Bank not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + code: ["Kode atau nama bank sudah dipakai"] + } + }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.bank.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Bank not found" }, { status: 404 }); + + const usageCount = await countBankUsage(existing.name); + if (usageCount > 0) { + return NextResponse.json( + { message: "Bank sedang dipakai di buyer dan tidak bisa dihapus." }, + { status: 409 } + ); + } + + await prisma.bank.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "BANK_DELETED", + entityType: "BANK", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Bank ${existing.code} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Bank not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/banks/route.ts b/src/app/api/v1/banks/route.ts new file mode 100644 index 0000000..5aa9b80 --- /dev/null +++ b/src/app/api/v1/banks/route.ts @@ -0,0 +1,71 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeBank } from "@/features/banks/lib/serialize-bank"; +import { bankInputSchema } from "@/features/banks/schemas/bank.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const banks = await prisma.bank.findMany({ + orderBy: [{ status: "asc" }, { code: "asc" }] + }); + + return NextResponse.json({ + data: banks.map(serializeBank) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = bankInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const bank = await prisma.bank.create({ + data: { + code: parsed.data.code, + name: parsed.data.name, + address: parsed.data.address || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "BANK_CREATED", + entityType: "BANK", + entityId: bank.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Bank ${bank.code} dibuat` + }); + + return NextResponse.json({ data: serializeBank(bank) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + code: ["Kode atau nama bank sudah dipakai"] + } + }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/buyers/[id]/route.ts b/src/app/api/v1/buyers/[id]/route.ts new file mode 100644 index 0000000..d2242bc --- /dev/null +++ b/src/app/api/v1/buyers/[id]/route.ts @@ -0,0 +1,265 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer"; +import { buyerInputSchema } from "@/features/buyers/schemas/buyer.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +function parseId(rawId: string) { + try { + return BigInt(rawId); + } catch { + return null; + } +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { id } = await context.params; + const parsedId = parseId(id); + + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const buyer = await prisma.buyer.findUnique({ + where: { id: parsedId }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + + if (!buyer) { + return NextResponse.json({ message: "Buyer not found" }, { status: 404 }); + } + + return NextResponse.json({ + data: serializeBuyer(buyer) + }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { id } = await context.params; + const parsedId = parseId(id); + + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const json = await request.json(); + const parsed = buyerInputSchema.safeParse(json); + + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 400 } + ); + } + + try { + const existing = await prisma.buyer.findUnique({ + where: { id: parsedId }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + + if (!existing) { + return NextResponse.json({ message: "Buyer not found" }, { status: 404 }); + } + + if (parsed.data.bank_name) { + const bank = await prisma.bank.findFirst({ + where: { + name: parsed.data.bank_name, + status: "ACTIVE" + } + }); + + if (!bank) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + bank_name: ["Bank harus dipilih dari master bank aktif untuk buyer"] + } + }, + { status: 400 } + ); + } + } + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "CUS", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => + prisma.buyer.count({ where: { code: { startsWith: "CUS" } } }), + exists: async (code) => + (await prisma.buyer.count({ + where: { + code, + id: { not: parsedId } + } + })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const buyer = await prisma.buyer.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + phone: parsed.data.phone || null, + email: parsed.data.email || null, + bankName: parsed.data.bank_name || null, + bankAccountNumber: parsed.data.bank_account_number || null, + address: parsed.data.address || null, + contactPeople: { + deleteMany: {}, + create: parsed.data.contact_people.map((contactPerson) => ({ + name: contactPerson.name, + mobilePhone: contactPerson.mobile_phone || null, + email: contactPerson.email || null + })) + }, + status: parsed.data.status + }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CUSTOMER_UPDATED", + entityType: "CUSTOMER", + entityId: buyer.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Buyer ${buyer.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + phone: existing.phone, + email: existing.email, + bank_name: existing.bankName, + bank_account_number: existing.bankAccountNumber, + address: existing.address, + contact_people: existing.contactPeople.map((contactPerson) => ({ + name: contactPerson.name, + mobile_phone: contactPerson.mobilePhone, + email: contactPerson.email + })), + status: existing.status + }, + { + code: buyer.code, + name: buyer.name, + phone: buyer.phone, + email: buyer.email, + bank_name: buyer.bankName, + bank_account_number: buyer.bankAccountNumber, + address: buyer.address, + contact_people: buyer.contactPeople.map((contactPerson) => ({ + name: contactPerson.name, + mobile_phone: contactPerson.mobilePhone, + email: contactPerson.email + })), + status: buyer.status + } + ) + }); + + return NextResponse.json({ + data: serializeBuyer(buyer) + }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Buyer not found" }, { status: 404 }); + } + + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + code: ["Kode pembeli sudah dipakai"] + } + }, + { status: 409 } + ); + } + + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { id } = await context.params; + const parsedId = parseId(id); + + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + try { + const existing = await prisma.buyer.findUnique({ where: { id: parsedId } }); + await prisma.buyer.delete({ + where: { id: parsedId } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CUSTOMER_DELETED", + entityType: "CUSTOMER", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Buyer ${existing?.code ?? parsedId.toString()} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Buyer not found" }, { status: 404 }); + } + + throw error; + } +} diff --git a/src/app/api/v1/buyers/route.ts b/src/app/api/v1/buyers/route.ts new file mode 100644 index 0000000..44e0a27 --- /dev/null +++ b/src/app/api/v1/buyers/route.ts @@ -0,0 +1,139 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer"; +import { buyerInputSchema } from "@/features/buyers/schemas/buyer.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const buyers = await prisma.buyer.findMany({ + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: buyers.map(serializeBuyer) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const json = await request.json(); + const parsed = buyerInputSchema.safeParse(json); + + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 400 } + ); + } + + try { + if (parsed.data.bank_name) { + const bank = await prisma.bank.findFirst({ + where: { + name: parsed.data.bank_name, + status: "ACTIVE" + } + }); + + if (!bank) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + bank_name: ["Bank harus dipilih dari master bank aktif untuk buyer"] + } + }, + { status: 400 } + ); + } + } + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "CUS", + requestedCode: parsed.data.code, + countExisting: () => + prisma.buyer.count({ where: { code: { startsWith: "CUS" } } }), + exists: async (code) => + (await prisma.buyer.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const buyer = await prisma.buyer.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + phone: parsed.data.phone || null, + email: parsed.data.email || null, + bankName: parsed.data.bank_name || null, + bankAccountNumber: parsed.data.bank_account_number || null, + address: parsed.data.address || null, + contactPeople: { + create: parsed.data.contact_people.map((contactPerson) => ({ + name: contactPerson.name, + mobilePhone: contactPerson.mobile_phone || null, + email: contactPerson.email || null + })) + }, + status: parsed.data.status + }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CUSTOMER_CREATED", + entityType: "CUSTOMER", + entityId: buyer.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Buyer ${buyer.code} dibuat` + }); + + return NextResponse.json( + { + data: serializeBuyer(buyer) + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + code: ["Kode pembeli sudah dipakai"] + } + }, + { status: 409 } + ); + } + + throw error; + } +} diff --git a/src/app/api/v1/consignments/[id]/route.ts b/src/app/api/v1/consignments/[id]/route.ts new file mode 100644 index 0000000..2cdc7d0 --- /dev/null +++ b/src/app/api/v1/consignments/[id]/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; + +import { serializeConsignmentDetail } from "@/features/consignments/lib/serialize-consignment"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const consignmentDetailInclude = { + sales: true, + buyer: true, + lines: { + include: { + lot: { + include: { + purchase: { + select: { + agent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } + } + } +} as const; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseBigInt((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const consignment = await prisma.consignment.findUnique({ + where: { id: parsedId }, + include: consignmentDetailInclude + }); + + if (!consignment) { + return NextResponse.json({ message: "Titip jual tidak ditemukan" }, { status: 404 }); + } + + return NextResponse.json({ + data: serializeConsignmentDetail(consignment) + }); +} diff --git a/src/app/api/v1/consignments/bootstrap/route.ts b/src/app/api/v1/consignments/bootstrap/route.ts new file mode 100644 index 0000000..4603762 --- /dev/null +++ b/src/app/api/v1/consignments/bootstrap/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; + +import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer"; +import { serializeConsignmentCandidateLot } from "@/features/consignments/lib/serialize-consignment"; +import { serializeSales } from "@/features/sales/lib/serialize-sales"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const [sales, buyers, lots] = await Promise.all([ + prisma.sales.findMany({ + include: { + bankAccounts: { + include: { + bank: true + } + } + }, + orderBy: [{ name: "asc" }] + }), + prisma.buyer.findMany({ + include: { + contactPeople: true + }, + where: { + status: "ACTIVE" + }, + orderBy: [{ name: "asc" }] + }), + prisma.inventoryLot.findMany({ + where: { + status: "ACTIVE", + availableQty: { + gt: 0 + } + }, + include: { + purchaseLine: { + select: { + malUnitPrice: true + } + }, + grade: { select: { name: true } }, + unit: { select: { code: true } }, + warehouse: { select: { name: true } }, + warehouseLocation: { select: { name: true } } + }, + orderBy: [{ createdAt: "desc" }] + }) + ]); + + return NextResponse.json({ + data: { + sales: sales.map(serializeSales), + buyers: buyers.map(serializeBuyer), + lots: lots.map(serializeConsignmentCandidateLot) + } + }); +} diff --git a/src/app/api/v1/consignments/lines/[lineId]/close/route.ts b/src/app/api/v1/consignments/lines/[lineId]/close/route.ts new file mode 100644 index 0000000..061aff9 --- /dev/null +++ b/src/app/api/v1/consignments/lines/[lineId]/close/route.ts @@ -0,0 +1,380 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { + AGENT_BALANCE_DIRECTIONS, + AGENT_BALANCE_SOURCES, + AGENT_BALANCE_TYPES, + createAgentBalanceMutation +} from "@/features/agents/lib/balance-mutations"; +import { consignmentCloseLineSchema } from "@/features/consignments/schemas/consignment.schema"; +import { + createSalesCommissionMutation, + SALES_COMMISSION_DIRECTIONS, + SALES_COMMISSION_SOURCES +} from "@/features/sales/lib/commission-mutations"; +import { buildAllocationShares, getEffectiveLotAllocations, roundAmount } from "@/features/purchase-realization/lib/lot-allocation"; +import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ lineId: string }> }; +type ConsignmentTx = Prisma.TransactionClient & { + purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry; + purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary; + lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation; +}; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedLineId = parseBigInt((await context.params).lineId); + if (parsedLineId === null) { + return NextResponse.json({ message: "Invalid line id" }, { status: 400 }); + } + + const parsed = consignmentCloseLineSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const closeDate = new Date(`${parsed.data.close_date}T00:00:00.000Z`); + if (Number.isNaN(closeDate.getTime())) { + return NextResponse.json({ message: "Tanggal close tidak valid" }, { status: 400 }); + } + + const existingLine = await prisma.consignmentLine.findUnique({ + where: { id: parsedLineId }, + include: { + lot: { + include: { + purchase: { + select: { + id: true, + agentId: true, + profitShareSchemeId: true, + agent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseAllocations: true + } + }, + consignment: true + } + }); + + if (!existingLine) { + return NextResponse.json({ message: "Item titip jual tidak ditemukan" }, { status: 404 }); + } + if (existingLine.status === "CLOSED") { + return NextResponse.json({ message: "Item sudah ditutup" }, { status: 409 }); + } + + const qtyConsigned = existingLine.qtyConsigned.toNumber(); + const qtySold = parsed.data.qty_sold; + const qtyReturned = parsed.data.qty_returned; + const salesCommission = parsed.data.sales_commission; + const resolvedQty = qtySold + qtyReturned; + + if (resolvedQty > qtyConsigned) { + return NextResponse.json( + { message: "Berat terjual + kembali tidak boleh melebihi berat titip" }, + { status: 422 } + ); + } + + const qtyShrinkage = Number((qtyConsigned - qtySold - qtyReturned).toFixed(3)); + const malUnitPrice = existingLine.malUnitPriceSnapshot?.toNumber() ?? 0; + const shareAgent = + existingLine.agentSharePercent?.toNumber() ?? + existingLine.lot.purchase?.profitShareScheme?.shareAgent.toNumber() ?? + 0; + const agentCommission = Number( + ((((qtySold * (parsed.data.selling_price - malUnitPrice)) - salesCommission) * shareAgent) / 100).toFixed(2) + ); + + const updated = await prisma.$transaction(async (rawTx) => { + const tx = rawTx as ConsignmentTx; + const nextAvailable = Number((existingLine.lot.availableQty.toNumber() + qtyReturned).toFixed(3)); + await tx.inventoryLot.update({ + where: { id: existingLine.lotId }, + data: { + availableQty: new Prisma.Decimal(nextAvailable), + shrinkageQty: new Prisma.Decimal( + Number((existingLine.lot.shrinkageQty.toNumber() + qtyShrinkage).toFixed(3)) + ), + status: nextAvailable <= 0 ? "DEPLETED" : "ACTIVE" + } + }); + + const line = await tx.consignmentLine.update({ + where: { id: parsedLineId }, + data: { + status: "CLOSED", + closeDate, + sellingPrice: new Prisma.Decimal(parsed.data.selling_price), + qtySold: new Prisma.Decimal(qtySold), + qtyReturned: new Prisma.Decimal(qtyReturned), + qtyShrinkage: new Prisma.Decimal(qtyShrinkage), + salesCommission: new Prisma.Decimal(salesCommission), + agentCommission: new Prisma.Decimal(agentCommission) + } + }); + + const affectedPurchaseIds = new Set(); + const allocations = getEffectiveLotAllocations(existingLine.lot); + const allocationBaseQty = allocations.reduce( + (sum, allocation) => sum + allocation.qtyAllocated.toNumber(), + 0 + ); + const soldAndShrinkageQty = Number((qtySold + qtyShrinkage).toFixed(3)); + const revenueNet = roundAmount((qtySold * parsed.data.selling_price) - salesCommission); + const shares = buildAllocationShares(allocations, allocationBaseQty, soldAndShrinkageQty); + + for (const share of shares) { + affectedPurchaseIds.add(share.allocation.purchaseId); + const soldQtyShare = + soldAndShrinkageQty > 0 + ? Number((qtySold * (share.affectedAllocationQty / soldAndShrinkageQty)).toFixed(3)) + : 0; + const shrinkageQtyShare = + soldAndShrinkageQty > 0 + ? Number((qtyShrinkage * (share.affectedAllocationQty / soldAndShrinkageQty)).toFixed(3)) + : 0; + const revenueShare = roundAmount( + soldAndShrinkageQty > 0 ? revenueNet * (share.affectedAllocationQty / soldAndShrinkageQty) : 0 + ); + + if (soldQtyShare > 0 || revenueShare > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: existingLine.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "CONSIGNMENT_REVENUE", + referenceType: "CONSIGNMENT", + referenceId: existingLine.consignmentId, + occurredAt: closeDate, + qtyIn: new Prisma.Decimal(0), + qtyOut: new Prisma.Decimal(soldQtyShare), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(revenueShare), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Revenue consignment ${existingLine.consignment.consignmentNo}` + } + }); + } + + if (qtyReturned > 0) { + const returnQtyShare = Number( + (qtyReturned * ((allocationBaseQty > 0 ? share.allocationQty / allocationBaseQty : 0))).toFixed(3) + ); + if (returnQtyShare > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: existingLine.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "CONSIGNMENT_RETURN", + referenceType: "CONSIGNMENT", + referenceId: existingLine.consignmentId, + occurredAt: closeDate, + qtyIn: new Prisma.Decimal(returnQtyShare), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Return consignment ${existingLine.consignment.consignmentNo}` + } + }); + } + } + + if (shrinkageQtyShare > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: existingLine.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "CONSIGNMENT_SHRINKAGE", + referenceType: "CONSIGNMENT", + referenceId: existingLine.consignmentId, + occurredAt: closeDate, + qtyIn: new Prisma.Decimal(0), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(shrinkageQtyShare), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Susut consignment ${existingLine.consignment.consignmentNo}` + } + }); + } + + if (share.allocation.id) { + await tx.lotPurchaseAllocation.update({ + where: { id: share.allocation.id }, + data: { + qtyAllocated: new Prisma.Decimal(share.remainingQty), + costTotalAllocated: new Prisma.Decimal( + roundAmount(share.remainingQty * share.allocation.unitCostSnapshot.toNumber()) + ) + } + }); + } + } + + if (salesCommission > 0) { + const updatedSales = await tx.sales.update({ + where: { id: existingLine.consignment.salesId }, + data: { + commissionBalance: { + increment: new Prisma.Decimal(salesCommission) + } + }, + select: { + commissionBalance: true + } + }); + + await createSalesCommissionMutation(tx, { + salesId: existingLine.consignment.salesId, + source: SALES_COMMISSION_SOURCES.CONSIGNMENT_COMMISSION, + direction: SALES_COMMISSION_DIRECTIONS.IN, + amount: salesCommission, + balanceAfter: updatedSales.commissionBalance.toNumber(), + effectiveDate: closeDate, + referenceType: "CONSIGNMENT", + referenceId: existingLine.consignmentId.toString(), + referenceNo: existingLine.consignment.consignmentNo, + notes: `Komisi sales dari titip jual ${existingLine.consignment.consignmentNo}`, + metadata: { + line_id: existingLine.id.toString(), + lot_code: existingLine.lot.lotCode + } + }); + } + + if (existingLine.lot.purchase?.agentId && agentCommission > 0) { + const updatedAgent = await tx.agent.update({ + where: { id: existingLine.lot.purchase.agentId }, + data: { + currentBalance: { + increment: new Prisma.Decimal(agentCommission) + } + }, + select: { + currentBalance: true + } + }); + + await createAgentBalanceMutation(tx, { + agentId: existingLine.lot.purchase.agentId, + balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE, + direction: AGENT_BALANCE_DIRECTIONS.IN, + source: AGENT_BALANCE_SOURCES.CONSIGNMENT_COMMISSION, + amount: agentCommission, + balanceAfter: updatedAgent.currentBalance.toNumber(), + effectiveDate: closeDate, + referenceType: "CONSIGNMENT", + referenceId: existingLine.consignmentId.toString(), + referenceNo: existingLine.consignment.consignmentNo, + notes: `Komisi agent dari titip jual ${existingLine.consignment.consignmentNo}` + }); + } + + const openLineCount = await tx.consignmentLine.count({ + where: { + consignmentId: existingLine.consignmentId, + status: { + not: "CLOSED" + } + } + }); + + await tx.consignment.update({ + where: { id: existingLine.consignmentId }, + data: { + status: openLineCount === 0 ? "CLOSED" : "PARTIAL_CLOSED" + } + }); + + for (const purchaseId of affectedPurchaseIds) { + const sourcePurchase = await tx.purchase.findUnique({ + where: { id: purchaseId }, + select: { + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }); + await recalculatePurchaseRealizationSummary( + tx, + purchaseId, + sourcePurchase?.profitShareScheme?.shareAgent.toNumber() ?? null + ); + } + + return line; + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CONSIGNMENT_LINE_CLOSED", + entityType: "CONSIGNMENT_LINE", + entityId: updated.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Item titip jual ${existingLine.lot.lotCode} ditutup`, + metadata: { + consignment_no: existingLine.consignment.consignmentNo, + lot_code: existingLine.lot.lotCode, + qty_sold: qtySold, + qty_returned: qtyReturned, + qty_shrinkage: qtyShrinkage, + sales_commission: salesCommission, + agent_commission: agentCommission + } + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/v1/consignments/route.ts b/src/app/api/v1/consignments/route.ts new file mode 100644 index 0000000..1c9c705 --- /dev/null +++ b/src/app/api/v1/consignments/route.ts @@ -0,0 +1,237 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { generateConsignmentNo } from "@/features/consignments/lib/generate-consignment-no"; +import { + serializeConsignmentDetail, + serializeConsignmentListItem +} from "@/features/consignments/lib/serialize-consignment"; +import { consignmentCreateSchema } from "@/features/consignments/schemas/consignment.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const consignmentInclude = { + sales: true, + buyer: true, + lines: true +} as const; + +const consignmentDetailInclude = { + sales: true, + buyer: true, + lines: { + include: { + lot: { + include: { + purchase: { + select: { + agent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } + } + } +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const consignments = await prisma.consignment.findMany({ + include: consignmentInclude, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: consignments.map(serializeConsignmentListItem) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = consignmentCreateSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const salesId = parseBigInt(parsed.data.sales_id); + const buyerId = parseBigInt(parsed.data.buyer_id); + const consignmentDate = new Date(`${parsed.data.consignment_date}T00:00:00.000Z`); + + if ( + salesId === null || + buyerId === null || + Number.isNaN(consignmentDate.getTime()) + ) { + return NextResponse.json({ message: "Invalid reference id or date" }, { status: 400 }); + } + + const lotIds = parsed.data.lines.map((line) => parseBigInt(line.lot_id)); + if (lotIds.some((id) => id === null)) { + return NextResponse.json({ message: "Lot tidak valid" }, { status: 400 }); + } + + const [sales, buyer, lots] = await Promise.all([ + prisma.sales.findUnique({ where: { id: salesId } }), + prisma.buyer.findUnique({ where: { id: buyerId } }), + prisma.inventoryLot.findMany({ + where: { + id: { + in: lotIds as bigint[] + } + }, + include: { + purchase: { + select: { + agent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseLine: { + select: { + malUnitPrice: true + } + } + } + }) + ]); + + if (!sales) { + return NextResponse.json({ message: "Sales tidak ditemukan" }, { status: 404 }); + } + if (!buyer) { + return NextResponse.json({ message: "Buyer tidak ditemukan" }, { status: 404 }); + } + + const lotMap = new Map(lots.map((lot) => [lot.id.toString(), lot])); + for (const line of parsed.data.lines) { + const lot = lotMap.get(line.lot_id); + if (!lot) { + return NextResponse.json({ message: `Lot ${line.lot_id} tidak ditemukan` }, { status: 404 }); + } + if (lot.status !== "ACTIVE") { + return NextResponse.json( + { message: `Lot ${lot.lotCode} tidak aktif untuk dititipkan` }, + { status: 422 } + ); + } + if (line.qty_consigned > lot.availableQty.toNumber()) { + return NextResponse.json( + { + message: `Qty titip untuk ${lot.lotCode} melebihi stok tersedia ${lot.availableQty.toNumber()}` + }, + { status: 422 } + ); + } + } + + const consignmentNo = await generateConsignmentNo(consignmentDate); + + const created = await prisma.$transaction(async (tx) => { + const consignment = await tx.consignment.create({ + data: { + consignmentNo, + consignmentDate, + salesId, + buyerId, + notes: parsed.data.notes ?? null, + createdById: BigInt(auth.user.id), + lines: { + create: parsed.data.lines.map((line) => { + const lot = lotMap.get(line.lot_id)!; + return { + lotId: lot.id, + qtyConsigned: new Prisma.Decimal(line.qty_consigned), + availableQtySnapshot: lot.availableQty, + malUnitPriceSnapshot: lot.purchaseLine?.malUnitPrice ?? null, + agentNameSnapshot: lot.purchase?.agent?.name ?? null, + agentSharePercent: lot.purchase?.profitShareScheme?.shareAgent ?? null, + status: "OPEN", + notes: line.notes ?? null + }; + }) + } + } + }); + + for (const line of parsed.data.lines) { + const lot = lotMap.get(line.lot_id)!; + const nextAvailable = Number((lot.availableQty.toNumber() - line.qty_consigned).toFixed(3)); + await tx.inventoryLot.update({ + where: { id: lot.id }, + data: { + availableQty: new Prisma.Decimal(nextAvailable), + status: nextAvailable <= 0 ? "DEPLETED" : "ACTIVE" + } + }); + } + + return tx.consignment.findUniqueOrThrow({ + where: { id: consignment.id }, + include: consignmentDetailInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CONSIGNMENT_CREATED", + entityType: "CONSIGNMENT", + entityId: created.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Titip jual ${created.consignmentNo} dibuat`, + metadata: { + consignment_no: created.consignmentNo, + sales_name: created.sales.name, + buyer_name: created.buyer.name, + line_count: created.lines.length + } + }); + + return NextResponse.json( + { + data: serializeConsignmentDetail(created) + }, + { status: 201 } + ); +} diff --git a/src/app/api/v1/couriers/[id]/route.ts b/src/app/api/v1/couriers/[id]/route.ts new file mode 100644 index 0000000..631ae57 --- /dev/null +++ b/src/app/api/v1/couriers/[id]/route.ts @@ -0,0 +1,159 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeCourier } from "@/features/couriers/lib/serialize-courier"; +import { courierInputSchema } from "@/features/couriers/schemas/courier.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const item = await prisma.courier.findUnique({ where: { id: parsedId } }); + if (!item) return NextResponse.json({ message: "Courier not found" }, { status: 404 }); + + return NextResponse.json({ data: serializeCourier(item) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = courierInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.courier.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Courier not found" }, { status: 404 }); + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "CUR", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => prisma.courier.count({ where: { code: { startsWith: "CUR" } } }), + exists: async (code) => (await prisma.courier.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const item = await prisma.courier.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + address: parsed.data.address || null, + phone: parsed.data.phone || null, + website: parsed.data.website || null, + email: parsed.data.email || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "COURIER_UPDATED", + entityType: "COURIER", + entityId: item.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Kurir ${item.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + address: existing.address, + phone: existing.phone, + website: existing.website, + email: existing.email, + status: existing.status + }, + { + code: item.code, + name: item.name, + address: item.address, + phone: item.phone, + website: item.website, + email: item.email, + status: item.status + } + ) + }); + + return NextResponse.json({ data: serializeCourier(item) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Courier not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode jasa pengiriman sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.courier.findUnique({ where: { id: parsedId } }); + await prisma.courier.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "COURIER_DELETED", + entityType: "COURIER", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Kurir ${existing?.code ?? parsedId.toString()} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Courier not found" }, { status: 404 }); + } + throw error; + } +} + diff --git a/src/app/api/v1/couriers/route.ts b/src/app/api/v1/couriers/route.ts new file mode 100644 index 0000000..feb5430 --- /dev/null +++ b/src/app/api/v1/couriers/route.ts @@ -0,0 +1,84 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeCourier } from "@/features/couriers/lib/serialize-courier"; +import { courierInputSchema } from "@/features/couriers/schemas/courier.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const items = await prisma.courier.findMany({ + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ data: items.map(serializeCourier) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = courierInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "CUR", + requestedCode: parsed.data.code, + countExisting: () => prisma.courier.count({ where: { code: { startsWith: "CUR" } } }), + exists: async (code) => (await prisma.courier.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const item = await prisma.courier.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + address: parsed.data.address || null, + phone: parsed.data.phone || null, + website: parsed.data.website || null, + email: parsed.data.email || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "COURIER_CREATED", + entityType: "COURIER", + entityId: item.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Kurir ${item.code} dibuat` + }); + + return NextResponse.json({ data: serializeCourier(item) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode jasa pengiriman sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + diff --git a/src/app/api/v1/currencies/[id]/route.ts b/src/app/api/v1/currencies/[id]/route.ts new file mode 100644 index 0000000..5748995 --- /dev/null +++ b/src/app/api/v1/currencies/[id]/route.ts @@ -0,0 +1,144 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeCurrency } from "@/features/currencies/lib/serialize-currency"; +import { currencyInputSchema } from "@/features/currencies/schemas/currency.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const currency = await prisma.currency.findUnique({ where: { id: parsedId } }); + if (!currency) return NextResponse.json({ message: "Currency not found" }, { status: 404 }); + + return NextResponse.json({ data: serializeCurrency(currency) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = currencyInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.currency.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Currency not found" }, { status: 404 }); + + const currency = await prisma.currency.update({ + where: { id: parsedId }, + data: { + code: parsed.data.code.toUpperCase(), + name: parsed.data.name, + description: parsed.data.description || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CURRENCY_UPDATED", + entityType: "CURRENCY", + entityId: currency.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Currency ${currency.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + description: existing.description, + status: existing.status + }, + { + code: currency.code, + name: currency.name, + description: currency.description, + status: currency.status + } + ) + }); + + return NextResponse.json({ data: serializeCurrency(currency) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Currency not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode mata uang sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.currency.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Currency not found" }, { status: 404 }); + + const settingsUsingCurrency = await prisma.appSetting.count({ + where: { currencyCode: existing.code } + }); + if (settingsUsingCurrency > 0) { + return NextResponse.json( + { message: "Currency sedang dipakai di pengaturan sistem dan tidak bisa dihapus." }, + { status: 409 } + ); + } + + await prisma.currency.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CURRENCY_DELETED", + entityType: "CURRENCY", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Currency ${existing.code} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Currency not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/currencies/route.ts b/src/app/api/v1/currencies/route.ts new file mode 100644 index 0000000..59b24e2 --- /dev/null +++ b/src/app/api/v1/currencies/route.ts @@ -0,0 +1,66 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies"; +import { serializeCurrency } from "@/features/currencies/lib/serialize-currency"; +import { currencyInputSchema } from "@/features/currencies/schemas/currency.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + await ensureDefaultCurrencies(); + const data = await prisma.currency.findMany({ + orderBy: [{ status: "asc" }, { code: "asc" }] + }); + + return NextResponse.json({ data: data.map(serializeCurrency) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = currencyInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const currency = await prisma.currency.create({ + data: { + code: parsed.data.code.toUpperCase(), + name: parsed.data.name, + description: parsed.data.description || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "CURRENCY_CREATED", + entityType: "CURRENCY", + entityId: currency.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Currency ${currency.code} dibuat` + }); + + return NextResponse.json({ data: serializeCurrency(currency) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode mata uang sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/employees/[id]/route.ts b/src/app/api/v1/employees/[id]/route.ts new file mode 100644 index 0000000..f25ff01 --- /dev/null +++ b/src/app/api/v1/employees/[id]/route.ts @@ -0,0 +1,138 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeEmployee } from "@/features/employees/lib/serialize-employee"; +import { employeeInputSchema } from "@/features/employees/schemas/employee.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const employee = await prisma.employee.findUnique({ where: { id: parsedId } }); + if (!employee) return NextResponse.json({ message: "Employee not found" }, { status: 404 }); + + return NextResponse.json({ data: serializeEmployee(employee) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = employeeInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.employee.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Employee not found" }, { status: 404 }); + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "EMP", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => prisma.employee.count({ where: { code: { startsWith: "EMP" } } }), + exists: async (code) => (await prisma.employee.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const employee = await prisma.employee.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + email: parsed.data.email || null, + mobile: parsed.data.mobile || null, + position: parsed.data.position, + address: parsed.data.address || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "EMPLOYEE_UPDATED", + entityType: "EMPLOYEE", + entityId: employee.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Employee ${employee.code} diubah` + }); + + return NextResponse.json({ data: serializeEmployee(employee) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Employee not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode karyawan sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.employee.findUnique({ where: { id: parsedId } }); + await prisma.employee.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "EMPLOYEE_DELETED", + entityType: "EMPLOYEE", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Employee ${existing?.code ?? parsedId.toString()} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Employee not found" }, { status: 404 }); + } + throw error; + } +} + diff --git a/src/app/api/v1/employees/route.ts b/src/app/api/v1/employees/route.ts new file mode 100644 index 0000000..e5a4eaa --- /dev/null +++ b/src/app/api/v1/employees/route.ts @@ -0,0 +1,86 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeEmployee } from "@/features/employees/lib/serialize-employee"; +import { employeeInputSchema } from "@/features/employees/schemas/employee.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + const items = await prisma.employee.findMany({ orderBy: [{ createdAt: "desc" }] }); + return NextResponse.json({ data: items.map(serializeEmployee) }); + } catch (error) { + const message = + process.env.NODE_ENV === "development" && error instanceof Error ? error.message : "Internal server error"; + return NextResponse.json({ message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = employeeInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "EMP", + requestedCode: parsed.data.code, + countExisting: () => prisma.employee.count({ where: { code: { startsWith: "EMP" } } }), + exists: async (code) => (await prisma.employee.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const employee = await prisma.employee.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + email: parsed.data.email || null, + mobile: parsed.data.mobile || null, + position: parsed.data.position, + address: parsed.data.address || null, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "EMPLOYEE_CREATED", + entityType: "EMPLOYEE", + entityId: employee.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Employee ${employee.code} dibuat` + }); + + return NextResponse.json({ data: serializeEmployee(employee) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode karyawan sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/fund-requests/bootstrap/route.ts b/src/app/api/v1/fund-requests/bootstrap/route.ts new file mode 100644 index 0000000..9ed5b43 --- /dev/null +++ b/src/app/api/v1/fund-requests/bootstrap/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; + +import { serializeAgent } from "@/features/agents/lib/serialize-agent"; +import { getAppSettings } from "@/lib/app-settings"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const settings = await getAppSettings(); + const settingRecord = await prisma.appSetting.findUnique({ + where: { singletonKey: "SYSTEM" }, + include: { + companyBankAccounts: { + include: { bank: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + const agents = await prisma.agent.findMany({ + include: { + profitShareScheme: true, + bankAccounts: { + include: { + bank: true + } + } + }, + orderBy: [{ name: "asc" }] + }); + + return NextResponse.json({ + data: { + agents: agents.map(serializeAgent), + company_bank_name: settings.company_bank_name, + company_bank_account_number: settings.company_bank_account_number, + company_bank_accounts: (settingRecord?.companyBankAccounts ?? []).map((account) => ({ + id: account.id.toString(), + bank_id: account.bankId.toString(), + bank_code: account.bank.code, + bank_name: account.bank.name, + account_number: account.accountNumber + })), + currency_code: settings.currency_code + } + }); +} diff --git a/src/app/api/v1/fund-requests/route.ts b/src/app/api/v1/fund-requests/route.ts new file mode 100644 index 0000000..1977346 --- /dev/null +++ b/src/app/api/v1/fund-requests/route.ts @@ -0,0 +1,268 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { + AGENT_BALANCE_DIRECTIONS, + AGENT_BALANCE_SOURCES, + AGENT_BALANCE_TYPES, + createAgentBalanceMutation, + roundBalanceAmount +} from "@/features/agents/lib/balance-mutations"; +import { generateFundRequestNo } from "@/features/fund-requests/lib/generate-fund-request-no"; +import { serializeFundRequest } from "@/features/fund-requests/lib/serialize-fund-request"; +import { + storeFundRequestProof, + validateFundRequestProofFile +} from "@/features/fund-requests/lib/store-transfer-proof"; +import { getAppSettings } from "@/lib/app-settings"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string | null) { + if (!value) return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const items = await prisma.fundRequest.findMany({ + include: { + agent: true, + agentBankAccount: true, + companyBankAccount: true + }, + orderBy: [{ transferredAt: "desc" }, { id: "desc" }] + }); + + return NextResponse.json({ + data: items.map(serializeFundRequest) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + const formData = await request.formData(); + const transferType = String(formData.get("transfer_type") ?? "").trim(); + const referenceNo = String(formData.get("reference_no") ?? "").trim(); + const agentId = parseBigInt(String(formData.get("agent_id") ?? "")); + const agentBankAccountId = parseBigInt(String(formData.get("agent_bank_account_id") ?? "")); + const companyBankAccountId = parseBigInt(String(formData.get("company_bank_account_id") ?? "")); + const amount = Number(formData.get("amount") ?? 0); + const transferredAtRaw = String(formData.get("transferred_at") ?? "").trim(); + const proofFile = formData.get("transfer_proof_file"); + + if (transferType !== "CAPITAL" && transferType !== "PROFIT_SHARE") { + return NextResponse.json({ message: "Tipe transfer tidak valid" }, { status: 422 }); + } + if (!referenceNo) { + return NextResponse.json({ message: "No reff wajib diisi" }, { status: 422 }); + } + if (agentId === null) { + return NextResponse.json({ message: "Agen wajib dipilih" }, { status: 422 }); + } + if (agentBankAccountId === null) { + return NextResponse.json({ message: "Nomor rekening agen wajib dipilih" }, { status: 422 }); + } + if (companyBankAccountId === null) { + return NextResponse.json({ message: "Rekening kantor wajib dipilih" }, { status: 422 }); + } + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ message: "Nominal transfer harus lebih dari 0" }, { status: 422 }); + } + if (!transferredAtRaw) { + return NextResponse.json({ message: "Waktu transfer wajib diisi" }, { status: 422 }); + } + + const transferredAt = new Date(transferredAtRaw); + if (Number.isNaN(transferredAt.getTime())) { + return NextResponse.json({ message: "Waktu transfer tidak valid" }, { status: 422 }); + } + + const settings = await getAppSettings(); + const settingRecord = await prisma.appSetting.findUnique({ + where: { singletonKey: "SYSTEM" }, + include: { + companyBankAccounts: { + include: { bank: true } + } + } + }); + if (!settingRecord || settingRecord.companyBankAccounts.length === 0) { + return NextResponse.json( + { message: "Lengkapi minimal satu rekening kantor di pengaturan sistem terlebih dahulu." }, + { status: 422 } + ); + } + + const agent = await prisma.agent.findUnique({ + where: { id: agentId }, + include: { + bankAccounts: { + include: { + bank: true + } + } + } + }); + + if (!agent) { + return NextResponse.json({ message: "Agen tidak ditemukan" }, { status: 404 }); + } + + const selectedBankAccount = agent.bankAccounts.find((item) => item.id === agentBankAccountId); + if (!selectedBankAccount || selectedBankAccount.bank.status !== "ACTIVE") { + return NextResponse.json({ message: "Rekening agen harus dipilih dari rekening aktif yang valid" }, { status: 422 }); + } + const selectedCompanyBankAccount = settingRecord.companyBankAccounts.find( + (item) => item.id === companyBankAccountId + ); + if (!selectedCompanyBankAccount || selectedCompanyBankAccount.bank.status !== "ACTIVE") { + return NextResponse.json({ message: "Rekening kantor harus dipilih dari rekening aktif yang valid" }, { status: 422 }); + } + + let proofFileUrl: string | null = null; + if (proofFile instanceof File && proofFile.size > 0) { + const validationError = validateFundRequestProofFile(proofFile); + if (validationError) { + return NextResponse.json({ message: validationError }, { status: 422 }); + } + proofFileUrl = await storeFundRequestProof(proofFile); + } + + const currentProfitShare = Number(agent.currentBalance); + const currentCapital = Number(agent.capitalBalance); + const nextProfitShare = + transferType === "PROFIT_SHARE" ? roundBalanceAmount(currentProfitShare - amount) : currentProfitShare; + const nextCapital = + transferType === "CAPITAL" ? roundBalanceAmount(currentCapital + amount) : currentCapital; + + if (nextProfitShare < 0) { + return NextResponse.json({ message: "Saldo bagi hasil agen tidak mencukupi" }, { status: 422 }); + } + + const requestNo = await generateFundRequestNo(transferredAt); + + const createdId = await prisma.$transaction(async (tx) => { + const saved = await tx.fundRequest.create({ + data: { + requestNo, + referenceNo, + transferType, + agentId, + agentBankAccountId, + companyBankAccountId, + agentBankNameSnapshot: selectedBankAccount.bank.name, + agentAccountNumberSnapshot: selectedBankAccount.accountNumber, + companyBankNameSnapshot: selectedCompanyBankAccount.bank.name, + companyAccountNumberSnapshot: selectedCompanyBankAccount.accountNumber, + amount: new Prisma.Decimal(roundBalanceAmount(amount)), + currencyCode: settings.currency_code, + transferredAt, + transferProofFileUrl: proofFileUrl, + status: "SUBMITTED", + createdById: BigInt(auth.user.id) + }, + include: { + agent: true, + agentBankAccount: true, + companyBankAccount: true + } + }); + + if (transferType === "PROFIT_SHARE") { + await tx.agent.update({ + where: { id: agentId }, + data: { + currentBalance: new Prisma.Decimal(nextProfitShare) + } + }); + + await createAgentBalanceMutation(tx, { + agentId, + balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE, + direction: AGENT_BALANCE_DIRECTIONS.OUT, + source: AGENT_BALANCE_SOURCES.FUND_REQUEST_PROFIT_SHARE, + amount, + balanceAfter: nextProfitShare, + effectiveDate: transferredAt, + referenceType: "FUND_REQUEST", + referenceId: saved.id.toString(), + referenceNo: saved.requestNo, + notes: `Transfer dana bagi hasil · ${referenceNo}`, + metadata: { + transfer_type: transferType, + reference_no: referenceNo + } + }); + } else { + await tx.agent.update({ + where: { id: agentId }, + data: { + capitalBalance: new Prisma.Decimal(nextCapital) + } + }); + + await createAgentBalanceMutation(tx, { + agentId, + balanceType: AGENT_BALANCE_TYPES.CAPITAL, + direction: AGENT_BALANCE_DIRECTIONS.IN, + source: AGENT_BALANCE_SOURCES.FUND_REQUEST_CAPITAL, + amount, + balanceAfter: nextCapital, + effectiveDate: transferredAt, + referenceType: "FUND_REQUEST", + referenceId: saved.id.toString(), + referenceNo: saved.requestNo, + notes: `Transfer dana modal · ${referenceNo}`, + metadata: { + transfer_type: transferType, + reference_no: referenceNo + } + }); + } + + return saved.id; + }); + + const created = await prisma.fundRequest.findUnique({ + where: { id: createdId }, + include: { + agent: true, + agentBankAccount: true, + companyBankAccount: true + } + }); + + if (!created) { + throw new Error("Fund request yang baru dibuat tidak ditemukan."); + } + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "FUND_REQUEST_CREATED", + entityType: "FUND_REQUEST", + entityId: created.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Fund request ${created.requestNo} dibuat` + }); + + return NextResponse.json({ data: serializeFundRequest(created) }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { message: error instanceof Error ? error.message : "Gagal menyimpan fund request" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/grades/[id]/route.ts b/src/app/api/v1/grades/[id]/route.ts new file mode 100644 index 0000000..a4453ee --- /dev/null +++ b/src/app/api/v1/grades/[id]/route.ts @@ -0,0 +1,274 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeGrade } from "@/features/grades/lib/serialize-grade"; +import { gradeInputSchema, type GradeInput } from "@/features/grades/schemas/grade.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +function getGradePrefix(isMangkok: boolean) { + return isMangkok ? "MGK" : "GRD"; +} + +function hasOverlap(items: GradeInput["buy_price_standards"]) { + const normalized = items.map((item) => ({ + start: new Date(item.start_date).getTime(), + end: item.end_date ? new Date(item.end_date).getTime() : Number.POSITIVE_INFINITY + })); + + for (let i = 0; i < normalized.length; i += 1) { + for (let j = i + 1; j < normalized.length; j += 1) { + const a = normalized[i]; + const b = normalized[j]; + if (a.start <= b.end && b.start <= a.end) { + return true; + } + } + } + + return false; +} + +function validatePriceStandards(parsed: GradeInput) { + if (hasOverlap(parsed.buy_price_standards)) { + return { buy_price_standards: ["Periode standar harga beli tidak boleh overlap"] }; + } + + if (hasOverlap(parsed.sell_price_standards)) { + return { sell_price_standards: ["Periode standar harga jual tidak boleh overlap"] }; + } + + return null; +} + +function buildCodeResolutionParams(parsed: GradeInput, existing: { code: string; isMangkok: boolean }, parsedId: bigint) { + const prefix = getGradePrefix(parsed.is_mangkok); + const existingMatchesPrefix = existing.code.startsWith(prefix); + const requestedCode = parsed.code?.trim(); + + return { + prefix, + requestedCode: requestedCode || undefined, + existingCode: requestedCode ? existing.code : existingMatchesPrefix ? existing.code : null, + countExisting: () => prisma.grade.count({ where: { code: { startsWith: prefix } } }), + exists: async (code: string) => + (await prisma.grade.count({ where: { code, id: { not: parsedId } } })) > 0 + }; +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const item = await prisma.grade.findUnique({ + where: { id: parsedId }, + include: { + buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }, + sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] } + } + }); + + if (!item) return NextResponse.json({ message: "Grade not found" }, { status: 404 }); + + return NextResponse.json({ data: serializeGrade(item) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = gradeInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const priceValidationError = validatePriceStandards(parsed.data); + if (priceValidationError) { + return NextResponse.json({ message: "Validasi gagal", errors: priceValidationError }, { status: 400 }); + } + + try { + const existing = await prisma.grade.findUnique({ + where: { id: parsedId }, + include: { + buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }, + sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] } + } + }); + if (!existing) return NextResponse.json({ message: "Grade not found" }, { status: 404 }); + + const resolution = buildCodeResolutionParams(parsed.data, existing, parsedId); + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: resolution.prefix, + requestedCode: resolution.requestedCode, + existingCode: resolution.existingCode, + countExisting: resolution.countExisting, + exists: resolution.exists + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const item = await prisma.grade.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + isMangkok: parsed.data.is_mangkok, + name: parsed.data.name, + description: parsed.data.description || null, + status: parsed.data.status, + buyPriceStandards: { + deleteMany: {}, + create: parsed.data.buy_price_standards.map((standard) => ({ + startDate: new Date(standard.start_date), + endDate: standard.end_date ? new Date(standard.end_date) : null, + minPrice: standard.min_price, + maxPrice: standard.max_price, + notes: standard.notes || null + })) + }, + sellPriceStandards: { + deleteMany: {}, + create: parsed.data.sell_price_standards.map((standard) => ({ + startDate: new Date(standard.start_date), + endDate: standard.end_date ? new Date(standard.end_date) : null, + minPrice: standard.min_price, + maxPrice: standard.max_price, + notes: standard.notes || null + })) + } + }, + include: { + buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }, + sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] } + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "GRADE_UPDATED", + entityType: "GRADE", + entityId: item.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Grade ${item.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + is_mangkok: existing.isMangkok, + name: existing.name, + description: existing.description, + status: existing.status, + buy_price_standards: existing.buyPriceStandards.map((standard) => ({ + start_date: standard.startDate.toISOString().slice(0, 10), + end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null, + min_price: Number(standard.minPrice), + max_price: Number(standard.maxPrice), + notes: standard.notes + })), + sell_price_standards: existing.sellPriceStandards.map((standard) => ({ + start_date: standard.startDate.toISOString().slice(0, 10), + end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null, + min_price: Number(standard.minPrice), + max_price: Number(standard.maxPrice), + notes: standard.notes + })) + }, + { + code: item.code, + is_mangkok: item.isMangkok, + name: item.name, + description: item.description, + status: item.status, + buy_price_standards: item.buyPriceStandards.map((standard) => ({ + start_date: standard.startDate.toISOString().slice(0, 10), + end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null, + min_price: Number(standard.minPrice), + max_price: Number(standard.maxPrice), + notes: standard.notes + })), + sell_price_standards: item.sellPriceStandards.map((standard) => ({ + start_date: standard.startDate.toISOString().slice(0, 10), + end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null, + min_price: Number(standard.minPrice), + max_price: Number(standard.maxPrice), + notes: standard.notes + })) + } + ) + }); + + return NextResponse.json({ data: serializeGrade(item) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Grade not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode grade sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.grade.findUnique({ where: { id: parsedId } }); + await prisma.grade.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "GRADE_DELETED", + entityType: "GRADE", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Grade ${existing?.code ?? parsedId.toString()} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Grade not found" }, { status: 404 }); + } + throw error; + } +} + diff --git a/src/app/api/v1/grades/route.ts b/src/app/api/v1/grades/route.ts new file mode 100644 index 0000000..f580f1b --- /dev/null +++ b/src/app/api/v1/grades/route.ts @@ -0,0 +1,149 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeGrade } from "@/features/grades/lib/serialize-grade"; +import { gradeInputSchema, type GradeInput } from "@/features/grades/schemas/grade.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +function getGradePrefix(isMangkok: boolean) { + return isMangkok ? "MGK" : "GRD"; +} + +function hasOverlap(items: GradeInput["buy_price_standards"]) { + const normalized = items.map((item) => ({ + start: new Date(item.start_date).getTime(), + end: item.end_date ? new Date(item.end_date).getTime() : Number.POSITIVE_INFINITY + })); + + for (let i = 0; i < normalized.length; i += 1) { + for (let j = i + 1; j < normalized.length; j += 1) { + const a = normalized[i]; + const b = normalized[j]; + if (a.start <= b.end && b.start <= a.end) { + return true; + } + } + } + + return false; +} + +function validatePriceStandards(parsed: GradeInput) { + if (hasOverlap(parsed.buy_price_standards)) { + return { buy_price_standards: ["Periode standar harga beli tidak boleh overlap"] }; + } + + if (hasOverlap(parsed.sell_price_standards)) { + return { sell_price_standards: ["Periode standar harga jual tidak boleh overlap"] }; + } + + return null; +} + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const data = await prisma.grade.findMany({ + include: { + buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }, + sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] } + }, + orderBy: [{ isMangkok: "desc" }, { createdAt: "desc" }] + }); + + return NextResponse.json({ data: data.map(serializeGrade) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = gradeInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const priceValidationError = validatePriceStandards(parsed.data); + if (priceValidationError) { + return NextResponse.json({ message: "Validasi gagal", errors: priceValidationError }, { status: 400 }); + } + + try { + const prefix = getGradePrefix(parsed.data.is_mangkok); + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix, + requestedCode: parsed.data.code, + countExisting: () => prisma.grade.count({ where: { code: { startsWith: prefix } } }), + exists: async (code) => (await prisma.grade.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const item = await prisma.grade.create({ + data: { + code: resolvedCode.code, + isMangkok: parsed.data.is_mangkok, + name: parsed.data.name, + description: parsed.data.description || null, + status: parsed.data.status, + buyPriceStandards: { + create: parsed.data.buy_price_standards.map((standard) => ({ + startDate: new Date(standard.start_date), + endDate: standard.end_date ? new Date(standard.end_date) : null, + minPrice: standard.min_price, + maxPrice: standard.max_price, + notes: standard.notes || null + })) + }, + sellPriceStandards: { + create: parsed.data.sell_price_standards.map((standard) => ({ + startDate: new Date(standard.start_date), + endDate: standard.end_date ? new Date(standard.end_date) : null, + minPrice: standard.min_price, + maxPrice: standard.max_price, + notes: standard.notes || null + })) + } + }, + include: { + buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }, + sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] } + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "GRADE_CREATED", + entityType: "GRADE", + entityId: item.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Grade ${item.code} dibuat` + }); + + return NextResponse.json({ data: serializeGrade(item) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode grade sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + diff --git a/src/app/api/v1/health/route.ts b/src/app/api/v1/health/route.ts new file mode 100644 index 0000000..8372af7 --- /dev/null +++ b/src/app/api/v1/health/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + await prisma.$queryRaw`SELECT 1`; + + return NextResponse.json({ + ok: true, + status: "ok", + service: "abelbirdnest-web", + timestamp: new Date().toISOString() + }); + } catch (error) { + return NextResponse.json( + { + ok: false, + status: "degraded", + service: "abelbirdnest-web", + timestamp: new Date().toISOString(), + message: error instanceof Error ? error.message : "Database health check gagal" + }, + { status: 503 } + ); + } +} diff --git a/src/app/api/v1/lot-transformations/[id]/route.ts b/src/app/api/v1/lot-transformations/[id]/route.ts new file mode 100644 index 0000000..3066c16 --- /dev/null +++ b/src/app/api/v1/lot-transformations/[id]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; + +import { serializeLotTransformationDetail } from "@/features/lot-transformations/lib/serialize-transformation"; +import { getLotTransformationById } from "@/features/lot-transformations/lib/transformation-query"; +import { requireApiAccess } from "@/lib/authorization"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const id = parseBigInt((await context.params).id); + if (id === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const item = await getLotTransformationById(id); + if (!item) { + return NextResponse.json({ message: "Transformation not found" }, { status: 404 }); + } + + return NextResponse.json({ + data: serializeLotTransformationDetail(item) + }); +} diff --git a/src/app/api/v1/lot-transformations/route.ts b/src/app/api/v1/lot-transformations/route.ts new file mode 100644 index 0000000..20083d0 --- /dev/null +++ b/src/app/api/v1/lot-transformations/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; + +import { createLotTransformation } from "@/features/lot-transformations/lib/create-lot-transformation"; +import { serializeLotTransformationListItem } from "@/features/lot-transformations/lib/serialize-transformation"; +import { lotTransformationSchema } from "@/features/lot-transformations/schemas/lot-transformation.schema"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const items = await prisma.lotTransformation.findMany({ + include: { + inputs: { select: { qtyUsed: true } }, + outputs: { select: { qtyProduced: true } } + }, + orderBy: [{ transformationDate: "desc" }, { createdAt: "desc" }] + }); + + return NextResponse.json({ + data: items.map(serializeLotTransformationListItem) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const body = await request.json(); + const parsed = lotTransformationSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + return createLotTransformation({ + payload: parsed.data, + actorUserId: auth.user.id, + requestMethod: request.method, + pathname: new URL(request.url).pathname + }); +} diff --git a/src/app/api/v1/lots/[id]/print-label/route.ts b/src/app/api/v1/lots/[id]/print-label/route.ts new file mode 100644 index 0000000..7172a14 --- /dev/null +++ b/src/app/api/v1/lots/[id]/print-label/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; + +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const lot = await prisma.inventoryLot.findUnique({ + where: { id: parsedId }, + include: { + grade: true, + warehouse: true, + warehouseLocation: true, + purchase: { + select: { + agent: { + select: { + id: true, + name: true + } + } + } + } + } + }); + + if (!lot) { + return NextResponse.json({ message: "Lot not found" }, { status: 404 }); + } + + const sourcePartyName = lot.purchase?.agent?.name ?? "Pembelian bebas"; + const gradeName = lot.grade?.name ?? null; + const qrValue = lot.qrCodeValue?.trim() || lot.barcodeValue?.trim() || lot.lotCode; + const barcodeValue = lot.barcodeValue?.trim() || lot.qrCodeValue?.trim() || lot.lotCode; + const pathname = new URL(request.url).pathname; + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "LOT_LABEL_PRINTED", + entityType: "LOT", + entityId: lot.id, + method: request.method, + pathname, + statusCode: 200, + summary: `Label lot ${lot.lotCode} dicetak`, + metadata: { + lot_code: lot.lotCode, + qr_value: qrValue, + barcode_value: barcodeValue, + source_type: lot.sourceType, + status: lot.status, + supplier_name: lot.purchase?.agent?.name ?? "Pembelian bebas", + grade: gradeName, + warehouse_name: lot.warehouse.name, + location_name: lot.warehouseLocation?.name ?? null, + printed_via: "WEB_LABEL_PRINT" + } + }); + + return NextResponse.json({ + data: { + printable_label: { + lot_id: lot.id.toString(), + lot_code: lot.lotCode, + qr_value: qrValue, + barcode_value: barcodeValue, + supplier: sourcePartyName, + grade: gradeName, + warehouse: lot.warehouse.name, + location: lot.warehouseLocation?.name ?? "-", + received_at: lot.receivedAt.toISOString(), + status: lot.status + } + } + }); +} diff --git a/src/app/api/v1/lots/[id]/route.ts b/src/app/api/v1/lots/[id]/route.ts new file mode 100644 index 0000000..958e878 --- /dev/null +++ b/src/app/api/v1/lots/[id]/route.ts @@ -0,0 +1,158 @@ +import { NextResponse } from "next/server"; + +import { serializeLotDetail } from "@/features/lots/lib/serialize-lot"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const lot = await prisma.inventoryLot.findUnique({ + where: { id: parsedId }, + include: { + grade: true, + warehouse: true, + warehouseLocation: true, + unit: true, + purchase: { + select: { + id: true, + purchaseNo: true, + purchaseDate: true, + agent: { + select: { + id: true, + name: true + } + }, + buyoutSourceAgent: { + select: { + id: true, + name: true + } + } + } + }, + purchaseLine: { + select: { + unitPrice: true, + buyoutMalUnitPriceSnapshot: true, + buyoutAgentSharePercent: true, + buyoutProfitAmount: true, + buyoutAgentCommission: true + } + }, + receipt: { select: { id: true, receiptNo: true, receiptDate: true } }, + parentLot: { select: { id: true, lotCode: true } }, + childLots: { + select: { + id: true, + lotCode: true, + status: true, + availableQty: true, + sourceType: true + } + }, + transformationOutputs: { + include: { + transformation: { + include: { + inputs: { + include: { + sourceLot: { + select: { + id: true, + lotCode: true, + grade: { select: { name: true } } + } + } + } + }, + outputs: { + include: { + resultLot: { + select: { + id: true, + lotCode: true, + availableQty: true, + unitCost: true, + status: true, + grade: { select: { name: true } } + } + } + } + } + } + } + } + }, + transformationInputs: { + include: { + transformation: { + include: { + inputs: { + include: { + sourceLot: { + select: { + id: true, + lotCode: true, + grade: { select: { name: true } } + } + } + } + }, + outputs: { + include: { + resultLot: { + select: { + id: true, + lotCode: true, + availableQty: true, + unitCost: true, + status: true, + grade: { select: { name: true } } + } + } + } + } + } + } + } + }, + consignmentLines: { + include: { + consignment: { + include: { + sales: { select: { name: true } }, + buyer: { select: { name: true } } + } + } + }, + orderBy: [{ createdAt: "desc" }] + } + } + }); + + if (!lot) { + return NextResponse.json({ message: "Lot not found" }, { status: 404 }); + } + + return NextResponse.json({ + data: serializeLotDetail(lot) + }); +} diff --git a/src/app/api/v1/lots/route.ts b/src/app/api/v1/lots/route.ts new file mode 100644 index 0000000..68f1e9c --- /dev/null +++ b/src/app/api/v1/lots/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; + +import { serializeLotListItem } from "@/features/lots/lib/serialize-lot"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const lots = await prisma.inventoryLot.findMany({ + include: { + purchase: { + select: { + id: true, + agent: { + select: { + id: true, + name: true + } + } + } + }, + grade: { select: { name: true } }, + unit: { select: { code: true } }, + warehouse: { select: { name: true } }, + warehouseLocation: { select: { name: true } } + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: lots.map(serializeLotListItem) + }); +} diff --git a/src/app/api/v1/mobile/bootstrap/route.ts b/src/app/api/v1/mobile/bootstrap/route.ts new file mode 100644 index 0000000..4eda477 --- /dev/null +++ b/src/app/api/v1/mobile/bootstrap/route.ts @@ -0,0 +1,190 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const modulesByRole: Record = { + OWNER: [ + "dashboard", + "lots", + "receipts", + "washing", + "stock_adjustments", + "lot_transformations", + "sales_regular", + "sales_jit", + "consignments", + "purchases", + "fund_requests", + "purchase_analyses", + "purchase_realizations" + ], + PURCHASING: [ + "dashboard", + "purchases", + "fund_requests", + "purchase_analyses", + "purchase_realizations" + ], + WAREHOUSE: [ + "dashboard", + "lots", + "receipts", + "washing", + "stock_adjustments" + ], + QC: [ + "dashboard", + "lots", + "washing", + "lot_transformations", + "stock_adjustments" + ], + SALES: [ + "dashboard", + "lots", + "sales_regular", + "sales_jit", + "consignments" + ], + ADMIN: [ + "dashboard", + "lots", + "receipts", + "washing", + "stock_adjustments", + "lot_transformations", + "sales_regular", + "sales_jit", + "consignments", + "purchases", + "fund_requests", + "purchase_analyses", + "purchase_realizations" + ], + SYSTEM_ADMIN: [ + "dashboard", + "lots", + "receipts", + "washing", + "stock_adjustments", + "lot_transformations", + "sales_regular", + "sales_jit", + "consignments", + "purchases", + "fund_requests", + "purchase_analyses", + "purchase_realizations" + ] + }; + + const [grades, warehouses, summary] = await Promise.all([ + prisma.grade.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }], + select: { id: true, code: true, name: true } + }), + prisma.warehouse.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }], + include: { + locations: { + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }], + select: { id: true, code: true, name: true, locationType: true } + } + } + }), + prisma.$transaction([ + prisma.inventoryLot.count({ + where: { + status: "ACTIVE", + availableQty: { gt: 0 } + } + }), + prisma.purchase.count({ + where: { + purchaseType: "REGULAR", + status: "DRAFT" + } + }), + prisma.purchase.count({ + where: { + purchaseType: "REGULAR", + status: "SUBMITTED" + } + }), + prisma.regularSale.count({ + where: { + status: "OPEN" + } + }), + prisma.consignmentLine.count({ + where: { + status: "OPEN" + } + }), + prisma.lotWashing.count({ + where: { + status: "IN_PROGRESS" + } + }) + ]) + ]); + + const [ + activeLotCount, + draftPurchaseCount, + submittedPurchaseCount, + openRegularSaleCount, + openConsignmentLineCount, + inProgressWashingCount + ] = summary; + + return NextResponse.json({ + data: { + user: auth.user, + modules: modulesByRole[auth.user.role] ?? ["dashboard"], + summary: { + active_lot_count: activeLotCount, + draft_purchase_count: draftPurchaseCount, + submitted_purchase_count: submittedPurchaseCount, + open_regular_sale_count: openRegularSaleCount, + open_consignment_line_count: openConsignmentLineCount, + in_progress_washing_count: inProgressWashingCount + }, + transformation_types: [ + { code: "MIX", label: "Mixing" }, + { code: "REGRADE", label: "Regrade" } + ], + remainder_modes: [ + { code: "KEEP_SOURCE_GRADE", label: "Simpan sisa di lot sumber" }, + { code: "SHRINKAGE", label: "Catat sebagai shrinkage" } + ], + processing_loss_modes: [ + { code: "SHRINKAGE", label: "Catat sebagai shrinkage" } + ], + grades: grades.map((item) => ({ + id: item.id.toString(), + code: item.code, + name: item.name + })), + warehouses: warehouses.map((warehouse) => ({ + id: warehouse.id.toString(), + code: warehouse.code, + name: warehouse.name, + locations: warehouse.locations.map((location) => ({ + id: location.id.toString(), + code: location.code, + name: location.name, + location_type: location.locationType + })) + })) + } + }); +} diff --git a/src/app/api/v1/mobile/consignments/[id]/route.ts b/src/app/api/v1/mobile/consignments/[id]/route.ts new file mode 100644 index 0000000..1725e3b --- /dev/null +++ b/src/app/api/v1/mobile/consignments/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/consignments/[id]/route"; diff --git a/src/app/api/v1/mobile/consignments/bootstrap/route.ts b/src/app/api/v1/mobile/consignments/bootstrap/route.ts new file mode 100644 index 0000000..8b5d1b5 --- /dev/null +++ b/src/app/api/v1/mobile/consignments/bootstrap/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/consignments/bootstrap/route"; diff --git a/src/app/api/v1/mobile/consignments/lines/[lineId]/close/route.ts b/src/app/api/v1/mobile/consignments/lines/[lineId]/close/route.ts new file mode 100644 index 0000000..a50a86b --- /dev/null +++ b/src/app/api/v1/mobile/consignments/lines/[lineId]/close/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/v1/consignments/lines/[lineId]/close/route"; diff --git a/src/app/api/v1/mobile/consignments/route.ts b/src/app/api/v1/mobile/consignments/route.ts new file mode 100644 index 0000000..45e033e --- /dev/null +++ b/src/app/api/v1/mobile/consignments/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/consignments/route"; diff --git a/src/app/api/v1/mobile/dashboard/route.ts b/src/app/api/v1/mobile/dashboard/route.ts new file mode 100644 index 0000000..c089cb0 --- /dev/null +++ b/src/app/api/v1/mobile/dashboard/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { getDashboardData } from "@/lib/dashboard"; +import type { AppLocale } from "@/lib/i18n"; + +function normalizeLocale(value: string | null): AppLocale { + return value === "en" ? "en" : "id"; +} + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { searchParams } = new URL(request.url); + const locale = normalizeLocale(searchParams.get("locale")); + const data = await getDashboardData(locale); + + return NextResponse.json({ data }); +} diff --git a/src/app/api/v1/mobile/fund-requests/bootstrap/route.ts b/src/app/api/v1/mobile/fund-requests/bootstrap/route.ts new file mode 100644 index 0000000..844b703 --- /dev/null +++ b/src/app/api/v1/mobile/fund-requests/bootstrap/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/fund-requests/bootstrap/route"; diff --git a/src/app/api/v1/mobile/fund-requests/route.ts b/src/app/api/v1/mobile/fund-requests/route.ts new file mode 100644 index 0000000..f551d2b --- /dev/null +++ b/src/app/api/v1/mobile/fund-requests/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/fund-requests/route"; diff --git a/src/app/api/v1/mobile/lot-transformations/[id]/route.ts b/src/app/api/v1/mobile/lot-transformations/[id]/route.ts new file mode 100644 index 0000000..64794f0 --- /dev/null +++ b/src/app/api/v1/mobile/lot-transformations/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/lot-transformations/[id]/route"; diff --git a/src/app/api/v1/mobile/lot-transformations/route.ts b/src/app/api/v1/mobile/lot-transformations/route.ts new file mode 100644 index 0000000..6638526 --- /dev/null +++ b/src/app/api/v1/mobile/lot-transformations/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; + +import { createLotTransformation } from "@/features/lot-transformations/lib/create-lot-transformation"; +import { mobileLotTransformationSchema } from "@/features/mobile/schemas/mobile-lot-transformation.schema"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; +export { GET } from "@/app/api/v1/lot-transformations/route"; + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const body = await request.json(); + const parsed = mobileLotTransformationSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const normalizedCodes = parsed.data.inputs.map((input) => input.source_lot_code.trim().toUpperCase()); + const sourceLots = await prisma.inventoryLot.findMany({ + where: { + lotCode: { + in: normalizedCodes + } + }, + select: { + id: true, + lotCode: true + } + }); + + const lotCodeMap = new Map(sourceLots.map((lot) => [lot.lotCode.toUpperCase(), lot.id.toString()])); + const missingCodes = normalizedCodes.filter((code) => !lotCodeMap.has(code)); + if (missingCodes.length > 0) { + return NextResponse.json( + { + message: "Sebagian lot sumber tidak ditemukan", + errors: { + inputs: missingCodes.map((code) => `Lot ${code} tidak ditemukan`) + } + }, + { status: 404 } + ); + } + + return createLotTransformation({ + payload: { + transformation_type: parsed.data.transformation_type, + transformation_date: parsed.data.transformation_date, + remainder_mode: parsed.data.remainder_mode ?? null, + processing_loss_mode: parsed.data.processing_loss_mode ?? null, + notes: parsed.data.notes ?? null, + inputs: parsed.data.inputs.map((input) => ({ + source_lot_id: lotCodeMap.get(input.source_lot_code.trim().toUpperCase())!, + qty_used: input.qty_used, + notes: input.notes ?? null + })), + outputs: parsed.data.outputs.map((output) => ({ + grade_id: output.grade_id, + warehouse_id: output.warehouse_id, + warehouse_location_id: output.warehouse_location_id ?? null, + qty_produced: output.qty_produced, + notes: output.notes ?? null + })) + }, + actorUserId: auth.user.id, + requestMethod: request.method, + pathname: new URL(request.url).pathname + }); +} diff --git a/src/app/api/v1/mobile/lots/[id]/route.ts b/src/app/api/v1/mobile/lots/[id]/route.ts new file mode 100644 index 0000000..2f793ea --- /dev/null +++ b/src/app/api/v1/mobile/lots/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/lots/[id]/route"; diff --git a/src/app/api/v1/mobile/lots/route.ts b/src/app/api/v1/mobile/lots/route.ts new file mode 100644 index 0000000..8d36400 --- /dev/null +++ b/src/app/api/v1/mobile/lots/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/lots/route"; diff --git a/src/app/api/v1/mobile/lots/scan/route.ts b/src/app/api/v1/mobile/lots/scan/route.ts new file mode 100644 index 0000000..9d9e638 --- /dev/null +++ b/src/app/api/v1/mobile/lots/scan/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from "next/server"; + +import { serializeLotDetail } from "@/features/lots/lib/serialize-lot"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const { searchParams } = new URL(request.url); + const rawCode = searchParams.get("code")?.trim(); + if (!rawCode) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { code: ["Kode scan wajib diisi"] } + }, + { status: 422 } + ); + } + + const code = rawCode.toUpperCase(); + const lot = await prisma.inventoryLot.findFirst({ + where: { + OR: [ + { lotCode: code }, + { qrCodeValue: code }, + { barcodeValue: code } + ] + }, + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true, + purchase: { + select: { + id: true, + purchaseNo: true, + purchaseDate: true, + agent: { + select: { + id: true, + name: true + } + }, + buyoutSourceAgent: { + select: { + id: true, + name: true + } + } + } + }, + purchaseLine: { + select: { + unitPrice: true, + buyoutMalUnitPriceSnapshot: true, + buyoutAgentSharePercent: true, + buyoutProfitAmount: true, + buyoutAgentCommission: true + } + }, + receipt: { select: { id: true, receiptNo: true, receiptDate: true } }, + parentLot: { select: { id: true, lotCode: true } }, + childLots: { + select: { + id: true, + lotCode: true, + status: true, + availableQty: true, + sourceType: true + } + }, + transformationOutputs: { + include: { + transformation: { + include: { + inputs: { + include: { + sourceLot: { + select: { + id: true, + lotCode: true, + grade: { select: { name: true } } + } + } + } + }, + outputs: { + include: { + resultLot: { + select: { + id: true, + lotCode: true, + availableQty: true, + unitCost: true, + status: true, + grade: { select: { name: true } } + } + } + } + } + } + } + } + }, + transformationInputs: { + include: { + transformation: { + include: { + inputs: { + include: { + sourceLot: { + select: { + id: true, + lotCode: true, + grade: { select: { name: true } } + } + } + } + }, + outputs: { + include: { + resultLot: { + select: { + id: true, + lotCode: true, + availableQty: true, + unitCost: true, + status: true, + grade: { select: { name: true } } + } + } + } + } + } + } + } + }, + consignmentLines: { + include: { + consignment: { + include: { + sales: { select: { name: true } }, + buyer: { select: { name: true } } + } + } + }, + orderBy: [{ createdAt: "desc" }] + } + } + }); + + if (!lot) { + return NextResponse.json({ message: "Lot tidak ditemukan" }, { status: 404 }); + } + + const availableQty = lot.availableQty.toNumber(); + const originalQty = lot.originalQty.toNumber(); + const damagedQty = lot.damagedQty.toNumber(); + const shrinkageQty = lot.shrinkageQty.toNumber(); + const reservedQty = lot.reservedQty.toNumber(); + const estimatedValue = Number((availableQty * lot.unitCost.toNumber()).toFixed(2)); + const movementCount = lot.transformationInputs.length + lot.transformationOutputs.length; + const sourcePartyName = lot.purchase?.agent?.name ?? "Pembelian bebas"; + const gradeName = lot.grade?.name ?? null; + + return NextResponse.json({ + data: { + summary_card: { + lot_code: lot.lotCode, + source_type: lot.sourceType, + status: lot.status, + grade: gradeName, + supplier_name: sourcePartyName, + warehouse_name: lot.warehouse.name, + warehouse_location_name: lot.warehouseLocation?.name ?? null, + available_qty: availableQty, + original_qty: originalQty, + reserved_qty: reservedQty, + damaged_qty: damagedQty, + shrinkage_qty: shrinkageQty, + unit_code: lot.unit.code, + unit_cost: lot.unitCost.toNumber(), + estimated_value: estimatedValue, + purchase_no: lot.purchase?.purchaseNo ?? null, + purchase_date: lot.purchase?.purchaseDate.toISOString() ?? null, + receipt_no: lot.receipt?.receiptNo ?? null, + receipt_date: lot.receipt?.receiptDate.toISOString() ?? null, + received_at: lot.receivedAt.toISOString(), + qr_code_value: lot.qrCodeValue, + barcode_value: lot.barcodeValue, + parent_lot_code: lot.parentLot?.lotCode ?? null, + child_lot_count: lot.childLots.length, + transformation_count: movementCount + }, + lot: serializeLotDetail(lot), + procurement: { + supplier_name: sourcePartyName, + purchase_no: lot.purchase?.purchaseNo ?? null, + purchase_date: lot.purchase?.purchaseDate.toISOString() ?? null, + receipt_no: lot.receipt?.receiptNo ?? null, + receipt_date: lot.receipt?.receiptDate.toISOString() ?? null, + received_at: lot.receivedAt.toISOString(), + source_type: lot.sourceType + }, + mobile_actions: { + can_mix: auth.user.role === "QC" || auth.user.role === "WAREHOUSE" || auth.user.role === "OWNER" || auth.user.role === "ADMIN" || auth.user.role === "SYSTEM_ADMIN", + can_regrade: auth.user.role === "QC" || auth.user.role === "WAREHOUSE" || auth.user.role === "OWNER" || auth.user.role === "ADMIN" || auth.user.role === "SYSTEM_ADMIN", + can_adjust: auth.user.role === "QC" || auth.user.role === "WAREHOUSE" || auth.user.role === "OWNER" || auth.user.role === "ADMIN" || auth.user.role === "SYSTEM_ADMIN" + } + } + }); +} diff --git a/src/app/api/v1/mobile/purchase-analyses/[purchaseId]/route.ts b/src/app/api/v1/mobile/purchase-analyses/[purchaseId]/route.ts new file mode 100644 index 0000000..a32378d --- /dev/null +++ b/src/app/api/v1/mobile/purchase-analyses/[purchaseId]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/purchase-analyses/[purchaseId]/route"; diff --git a/src/app/api/v1/mobile/purchase-analyses/route.ts b/src/app/api/v1/mobile/purchase-analyses/route.ts new file mode 100644 index 0000000..ee8259f --- /dev/null +++ b/src/app/api/v1/mobile/purchase-analyses/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/purchase-analyses/route"; diff --git a/src/app/api/v1/mobile/purchase-realizations/[purchaseId]/route.ts b/src/app/api/v1/mobile/purchase-realizations/[purchaseId]/route.ts new file mode 100644 index 0000000..45de003 --- /dev/null +++ b/src/app/api/v1/mobile/purchase-realizations/[purchaseId]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/purchase-realizations/[purchaseId]/route"; diff --git a/src/app/api/v1/mobile/purchase-realizations/route.ts b/src/app/api/v1/mobile/purchase-realizations/route.ts new file mode 100644 index 0000000..928df96 --- /dev/null +++ b/src/app/api/v1/mobile/purchase-realizations/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/purchase-realizations/route"; diff --git a/src/app/api/v1/mobile/purchases/[id]/cancel/route.ts b/src/app/api/v1/mobile/purchases/[id]/cancel/route.ts new file mode 100644 index 0000000..5c97c76 --- /dev/null +++ b/src/app/api/v1/mobile/purchases/[id]/cancel/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/v1/purchases/[id]/cancel/route"; diff --git a/src/app/api/v1/mobile/purchases/[id]/route.ts b/src/app/api/v1/mobile/purchases/[id]/route.ts new file mode 100644 index 0000000..d675981 --- /dev/null +++ b/src/app/api/v1/mobile/purchases/[id]/route.ts @@ -0,0 +1 @@ +export { GET, PUT } from "@/app/api/v1/purchases/[id]/route"; diff --git a/src/app/api/v1/mobile/purchases/[id]/submit/route.ts b/src/app/api/v1/mobile/purchases/[id]/submit/route.ts new file mode 100644 index 0000000..cb3541d --- /dev/null +++ b/src/app/api/v1/mobile/purchases/[id]/submit/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/v1/purchases/[id]/submit/route"; diff --git a/src/app/api/v1/mobile/purchases/route.ts b/src/app/api/v1/mobile/purchases/route.ts new file mode 100644 index 0000000..adf2f24 --- /dev/null +++ b/src/app/api/v1/mobile/purchases/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/purchases/route"; diff --git a/src/app/api/v1/mobile/receipts/[id]/generate-lots/route.ts b/src/app/api/v1/mobile/receipts/[id]/generate-lots/route.ts new file mode 100644 index 0000000..82e4f7e --- /dev/null +++ b/src/app/api/v1/mobile/receipts/[id]/generate-lots/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/v1/receipts/[id]/generate-lots/route"; diff --git a/src/app/api/v1/mobile/receipts/[id]/route.ts b/src/app/api/v1/mobile/receipts/[id]/route.ts new file mode 100644 index 0000000..fcaa99d --- /dev/null +++ b/src/app/api/v1/mobile/receipts/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/receipts/[id]/route"; diff --git a/src/app/api/v1/mobile/receipts/bootstrap/route.ts b/src/app/api/v1/mobile/receipts/bootstrap/route.ts new file mode 100644 index 0000000..3de3e22 --- /dev/null +++ b/src/app/api/v1/mobile/receipts/bootstrap/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const [purchases, warehouses] = await Promise.all([ + prisma.purchase.findMany({ + where: { + purchaseType: "REGULAR", + status: "SUBMITTED", + receipts: { + none: {} + } + }, + include: { + agent: { + select: { + id: true, + name: true + } + }, + lines: { + include: { + grade: { + select: { + id: true, + code: true, + name: true + } + }, + unit: { + select: { + id: true, + code: true + } + }, + warehouse: { + select: { + id: true, + name: true + } + }, + warehouseLocation: { + select: { + id: true, + name: true + } + } + } + } + }, + orderBy: [{ purchaseDate: "desc" }, { createdAt: "desc" }] + }), + prisma.warehouse.findMany({ + where: { status: "ACTIVE" }, + include: { + locations: { + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }], + select: { + id: true, + code: true, + name: true, + locationType: true + } + } + }, + orderBy: [{ name: "asc" }] + }) + ]); + + return NextResponse.json({ + data: { + purchases: purchases.map((purchase) => ({ + id: purchase.id.toString(), + purchase_no: purchase.purchaseNo, + purchase_date: purchase.purchaseDate.toISOString().slice(0, 10), + received_at: purchase.receivedAt?.toISOString() ?? null, + supplier_name: purchase.agent?.name ?? "Pembelian bebas", + notes: purchase.notes, + lines: purchase.lines.map((line) => ({ + id: line.id.toString(), + purchase_line_id: line.id.toString(), + grade: line.grade + ? { + id: line.grade.id.toString(), + code: line.grade.code, + name: line.grade.name + } + : null, + unit: { + id: line.unit.id.toString(), + code: line.unit.code + }, + qty_ordered: line.qtyOrdered.toNumber(), + qty_received: line.qtyReceived.toNumber(), + qty_accepted: line.qtyAccepted.toNumber(), + qty_rejected: line.qtyRejected.toNumber(), + unit_cost: line.unitCost.toNumber(), + warehouse_id: line.warehouse?.id?.toString() ?? null, + warehouse_location_id: line.warehouseLocation?.id?.toString() ?? null + })) + })), + warehouses: warehouses.map((warehouse) => ({ + id: warehouse.id.toString(), + code: warehouse.code, + name: warehouse.name, + locations: warehouse.locations.map((location) => ({ + id: location.id.toString(), + code: location.code, + name: location.name, + location_type: location.locationType + })) + })) + } + }); +} diff --git a/src/app/api/v1/mobile/receipts/route.ts b/src/app/api/v1/mobile/receipts/route.ts new file mode 100644 index 0000000..a646cd2 --- /dev/null +++ b/src/app/api/v1/mobile/receipts/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/receipts/route"; diff --git a/src/app/api/v1/mobile/sales-jit/[id]/close/route.ts b/src/app/api/v1/mobile/sales-jit/[id]/close/route.ts new file mode 100644 index 0000000..883cb03 --- /dev/null +++ b/src/app/api/v1/mobile/sales-jit/[id]/close/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/v1/sales-jit/[id]/close/route"; diff --git a/src/app/api/v1/mobile/sales-jit/[id]/route.ts b/src/app/api/v1/mobile/sales-jit/[id]/route.ts new file mode 100644 index 0000000..550bd1c --- /dev/null +++ b/src/app/api/v1/mobile/sales-jit/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/sales-jit/[id]/route"; diff --git a/src/app/api/v1/mobile/sales-jit/bootstrap/route.ts b/src/app/api/v1/mobile/sales-jit/bootstrap/route.ts new file mode 100644 index 0000000..c4a277a --- /dev/null +++ b/src/app/api/v1/mobile/sales-jit/bootstrap/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/sales-jit/bootstrap/route"; diff --git a/src/app/api/v1/mobile/sales-jit/route.ts b/src/app/api/v1/mobile/sales-jit/route.ts new file mode 100644 index 0000000..ef8119a --- /dev/null +++ b/src/app/api/v1/mobile/sales-jit/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/sales-jit/route"; diff --git a/src/app/api/v1/mobile/sales-regular/[id]/close/route.ts b/src/app/api/v1/mobile/sales-regular/[id]/close/route.ts new file mode 100644 index 0000000..fb20c46 --- /dev/null +++ b/src/app/api/v1/mobile/sales-regular/[id]/close/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/v1/sales-regular/[id]/close/route"; diff --git a/src/app/api/v1/mobile/sales-regular/[id]/route.ts b/src/app/api/v1/mobile/sales-regular/[id]/route.ts new file mode 100644 index 0000000..93ba531 --- /dev/null +++ b/src/app/api/v1/mobile/sales-regular/[id]/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/sales-regular/[id]/route"; diff --git a/src/app/api/v1/mobile/sales-regular/bootstrap/route.ts b/src/app/api/v1/mobile/sales-regular/bootstrap/route.ts new file mode 100644 index 0000000..8dc2b71 --- /dev/null +++ b/src/app/api/v1/mobile/sales-regular/bootstrap/route.ts @@ -0,0 +1 @@ +export { GET } from "@/app/api/v1/sales-regular/bootstrap/route"; diff --git a/src/app/api/v1/mobile/sales-regular/route.ts b/src/app/api/v1/mobile/sales-regular/route.ts new file mode 100644 index 0000000..33fdb44 --- /dev/null +++ b/src/app/api/v1/mobile/sales-regular/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/sales-regular/route"; diff --git a/src/app/api/v1/mobile/stock-adjustments/bootstrap/route.ts b/src/app/api/v1/mobile/stock-adjustments/bootstrap/route.ts new file mode 100644 index 0000000..7565372 --- /dev/null +++ b/src/app/api/v1/mobile/stock-adjustments/bootstrap/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const reasons = await prisma.adjustmentReason.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ category: "asc" }, { name: "asc" }] + }); + + return NextResponse.json({ + data: { + adjustment_reasons: reasons.map((reason) => ({ + id: reason.id.toString(), + code: reason.code, + name: reason.name, + category: reason.category + })) + } + }); +} diff --git a/src/app/api/v1/mobile/stock-adjustments/route.ts b/src/app/api/v1/mobile/stock-adjustments/route.ts new file mode 100644 index 0000000..451d4f4 --- /dev/null +++ b/src/app/api/v1/mobile/stock-adjustments/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/stock-adjustments/route"; diff --git a/src/app/api/v1/mobile/washing/[id]/complete/route.ts b/src/app/api/v1/mobile/washing/[id]/complete/route.ts new file mode 100644 index 0000000..e961719 --- /dev/null +++ b/src/app/api/v1/mobile/washing/[id]/complete/route.ts @@ -0,0 +1 @@ +export { POST } from "@/app/api/v1/washing/[id]/complete/route"; diff --git a/src/app/api/v1/mobile/washing/[id]/route.ts b/src/app/api/v1/mobile/washing/[id]/route.ts new file mode 100644 index 0000000..e7c3539 --- /dev/null +++ b/src/app/api/v1/mobile/washing/[id]/route.ts @@ -0,0 +1 @@ +export { PUT } from "@/app/api/v1/washing/[id]/route"; diff --git a/src/app/api/v1/mobile/washing/bootstrap/route.ts b/src/app/api/v1/mobile/washing/bootstrap/route.ts new file mode 100644 index 0000000..8b0f45e --- /dev/null +++ b/src/app/api/v1/mobile/washing/bootstrap/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const [washingPlaces, grades, warehouses] = await Promise.all([ + prisma.washingPlace.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }], + select: { + id: true, + code: true, + name: true + } + }), + prisma.grade.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }], + select: { + id: true, + code: true, + name: true + } + }), + prisma.warehouse.findMany({ + where: { status: "ACTIVE" }, + include: { + locations: { + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }], + select: { + id: true, + code: true, + name: true, + locationType: true + } + } + }, + orderBy: [{ name: "asc" }] + }) + ]); + + return NextResponse.json({ + data: { + washing_places: washingPlaces.map((item) => ({ + id: item.id.toString(), + code: item.code, + name: item.name + })), + grades: grades.map((item) => ({ + id: item.id.toString(), + code: item.code, + name: item.name + })), + warehouses: warehouses.map((warehouse) => ({ + id: warehouse.id.toString(), + code: warehouse.code, + name: warehouse.name, + locations: warehouse.locations.map((location) => ({ + id: location.id.toString(), + code: location.code, + name: location.name, + location_type: location.locationType + })) + })) + } + }); +} diff --git a/src/app/api/v1/mobile/washing/route.ts b/src/app/api/v1/mobile/washing/route.ts new file mode 100644 index 0000000..e249dea --- /dev/null +++ b/src/app/api/v1/mobile/washing/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/api/v1/washing/route"; diff --git a/src/app/api/v1/profit-share-schemes/[id]/route.ts b/src/app/api/v1/profit-share-schemes/[id]/route.ts new file mode 100644 index 0000000..719a0f0 --- /dev/null +++ b/src/app/api/v1/profit-share-schemes/[id]/route.ts @@ -0,0 +1,146 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeProfitShareScheme } from "@/features/profit-share-schemes/lib/serialize-profit-share-scheme"; +import { profitShareSchemeInputSchema } from "@/features/profit-share-schemes/schemas/profit-share-scheme.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const scheme = await prisma.profitShareScheme.findUnique({ where: { id: parsedId } }); + if (!scheme) return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 }); + return NextResponse.json({ data: serializeProfitShareScheme(scheme) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const parsed = profitShareSchemeInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.profitShareScheme.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 }); + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "PSS", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => prisma.profitShareScheme.count({ where: { code: { startsWith: "PSS" } } }), + exists: async (code) => + (await prisma.profitShareScheme.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const scheme = await prisma.profitShareScheme.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + shareAgent: parsed.data.share_agent, + shareCompany: parsed.data.share_company, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PROFIT_SHARE_SCHEME_UPDATED", + entityType: "PROFIT_SHARE_SCHEME", + entityId: scheme.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Profit share scheme ${scheme.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + share_agent: Number(existing.shareAgent), + share_company: Number(existing.shareCompany), + status: existing.status + }, + { + code: scheme.code, + name: scheme.name, + share_agent: Number(scheme.shareAgent), + share_company: Number(scheme.shareCompany), + status: scheme.status + } + ) + }); + + return NextResponse.json({ data: serializeProfitShareScheme(scheme) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode skema sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + try { + const existing = await prisma.profitShareScheme.findUnique({ where: { id: parsedId } }); + await prisma.profitShareScheme.delete({ where: { id: parsedId } }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PROFIT_SHARE_SCHEME_DELETED", + entityType: "PROFIT_SHARE_SCHEME", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Profit share scheme ${existing?.code ?? parsedId.toString()} dihapus` + }); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/profit-share-schemes/route.ts b/src/app/api/v1/profit-share-schemes/route.ts new file mode 100644 index 0000000..f778841 --- /dev/null +++ b/src/app/api/v1/profit-share-schemes/route.ts @@ -0,0 +1,76 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeProfitShareScheme } from "@/features/profit-share-schemes/lib/serialize-profit-share-scheme"; +import { profitShareSchemeInputSchema } from "@/features/profit-share-schemes/schemas/profit-share-scheme.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const data = await prisma.profitShareScheme.findMany({ orderBy: [{ createdAt: "desc" }] }); + return NextResponse.json({ data: data.map(serializeProfitShareScheme) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const parsed = profitShareSchemeInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "PSS", + requestedCode: parsed.data.code, + countExisting: () => prisma.profitShareScheme.count({ where: { code: { startsWith: "PSS" } } }), + exists: async (code) => (await prisma.profitShareScheme.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const scheme = await prisma.profitShareScheme.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + shareAgent: parsed.data.share_agent, + shareCompany: parsed.data.share_company, + status: parsed.data.status + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PROFIT_SHARE_SCHEME_CREATED", + entityType: "PROFIT_SHARE_SCHEME", + entityId: scheme.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Profit share scheme ${scheme.code} dibuat` + }); + + return NextResponse.json({ data: serializeProfitShareScheme(scheme) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode skema sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/purchase-analyses/[purchaseId]/route.ts b/src/app/api/v1/purchase-analyses/[purchaseId]/route.ts new file mode 100644 index 0000000..8f1782f --- /dev/null +++ b/src/app/api/v1/purchase-analyses/[purchaseId]/route.ts @@ -0,0 +1,347 @@ +import { NextResponse } from "next/server"; + +import { + purchaseAnalysisInputSchema, + type PurchaseAnalysisInput +} from "@/features/purchase-analysis/schemas/purchase-analysis.schema"; +import { serializePurchaseAnalysisDetail } from "@/features/purchase-analysis/lib/serialize-purchase-analysis"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { + params: Promise<{ + purchaseId: string; + }>; +}; + +const analysisInclude = { + agent: { + select: { + id: true, + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + }, + lines: { + select: { + subtotal: true, + qtyOrdered: true, + qtyReceived: true, + qtyAccepted: true, + unitPrice: true, + malUnitPrice: true, + moistureReceivedPercent: true, + purchaseMoisturePercent: true, + marketReferencePrice: true, + unit: { + select: { + code: true + } + } + } + }, + lots: { + select: { + originalQty: true, + finalMoisturePercent: true, + aboveAverageRatioPercent: true, + unit: { + select: { + code: true + } + } + } + }, + analysis: { + include: { + costEntries: true + } + } +} as const; + +function parseId(rawId: string) { + try { + return BigInt(rawId); + } catch { + return null; + } +} + +function toDecimal(value: number | null | undefined) { + if (value === undefined || value === null) return null; + return value; +} + +async function upsertAnalysis(purchaseId: bigint, payload: PurchaseAnalysisInput) { + return prisma.purchaseAnalysis.upsert({ + where: { purchaseId }, + update: { + status: payload.status, + weightBuy: toDecimal(payload.weight_buy), + weightReceived: toDecimal(payload.weight_received), + weightFinal: toDecimal(payload.weight_final), + moistureBuyPercent: toDecimal(payload.moisture_buy_percent), + moistureReceivedPercent: toDecimal(payload.moisture_received_percent), + moistureFinalPercent: toDecimal(payload.moisture_final_percent), + aboveAverageRatioPercent: toDecimal(payload.above_average_ratio_percent), + averagePrice: toDecimal(payload.average_price), + modalBeli: toDecimal(payload.modal_beli), + modalMasuk: toDecimal(payload.modal_masuk), + modalJual: toDecimal(payload.modal_jual), + modalBarang: toDecimal(payload.modal_barang), + totalModalBeli: toDecimal(payload.total_modal_beli), + totalModalMal: toDecimal(payload.total_modal_mal), + marketReferencePrice: toDecimal(payload.market_reference_price), + marketValuationTotal: toDecimal(payload.market_valuation_total), + agentProfitShareTotal: payload.agent_profit_share_total, + notes: payload.notes || null, + costEntries: { + deleteMany: {}, + create: payload.cost_entries.map((entry) => ({ + costType: entry.cost_type, + description: entry.description || null, + amount: entry.amount + })) + } + }, + create: { + purchaseId, + status: payload.status, + weightBuy: toDecimal(payload.weight_buy), + weightReceived: toDecimal(payload.weight_received), + weightFinal: toDecimal(payload.weight_final), + moistureBuyPercent: toDecimal(payload.moisture_buy_percent), + moistureReceivedPercent: toDecimal(payload.moisture_received_percent), + moistureFinalPercent: toDecimal(payload.moisture_final_percent), + aboveAverageRatioPercent: toDecimal(payload.above_average_ratio_percent), + averagePrice: toDecimal(payload.average_price), + modalBeli: toDecimal(payload.modal_beli), + modalMasuk: toDecimal(payload.modal_masuk), + modalJual: toDecimal(payload.modal_jual), + modalBarang: toDecimal(payload.modal_barang), + totalModalBeli: toDecimal(payload.total_modal_beli), + totalModalMal: toDecimal(payload.total_modal_mal), + marketReferencePrice: toDecimal(payload.market_reference_price), + marketValuationTotal: toDecimal(payload.market_valuation_total), + agentProfitShareTotal: payload.agent_profit_share_total, + notes: payload.notes || null, + costEntries: { + create: payload.cost_entries.map((entry) => ({ + costType: entry.cost_type, + description: entry.description || null, + amount: entry.amount + })) + } + } + }); +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).purchaseId); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid purchase id" }, { status: 400 }); + } + + try { + const purchase = await prisma.purchase.findUnique({ + where: { id: parsedId }, + select: { + id: true, + purchaseNo: true, + purchaseDate: true, + status: true, + moistureBuyPercent: true, + moistureReceivedPercent: true, + aboveAverageRatioPercent: true, + mkSharePercent: true, + nonMkSharePercent: true, + shippingCost: true, + incomingOperationalCost: true, + afterArrivalOperationalCost: true, + agent: analysisInclude.agent, + profitShareScheme: analysisInclude.profitShareScheme, + lines: analysisInclude.lines, + lots: analysisInclude.lots, + analysis: analysisInclude.analysis + } + }); + + if (!purchase) { + return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + } + if (purchase.status !== "SUBMITTED") { + return NextResponse.json( + { message: "Purchase analysis hanya tersedia untuk purchase final" }, + { status: 404 } + ); + } + + return NextResponse.json({ + data: serializePurchaseAnalysisDetail(purchase) + }); + } catch (error) { + console.error(`Failed to load purchase analysis detail for ${parsedId.toString()}:`, error); + return NextResponse.json( + { message: "Gagal memuat detail analisis pembelian" }, + { status: 500 } + ); + } +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).purchaseId); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid purchase id" }, { status: 400 }); + } + + let requestPayload: unknown; + try { + requestPayload = await request.json(); + } catch { + return NextResponse.json({ message: "Invalid request body" }, { status: 400 }); + } + + const parsed = purchaseAnalysisInputSchema.safeParse(requestPayload); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 400 } + ); + } + + try { + const purchase = await prisma.purchase.findUnique({ + where: { id: parsedId }, + select: { id: true } + }); + if (!purchase) { + return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + } + + const existingAnalysis = await prisma.purchaseAnalysis.findUnique({ + where: { purchaseId: parsedId }, + include: { costEntries: true } + }); + + await upsertAnalysis(parsedId, parsed.data); + + const refreshed = await prisma.purchase.findUnique({ + where: { id: parsedId }, + select: { + id: true, + purchaseNo: true, + purchaseDate: true, + status: true, + moistureBuyPercent: true, + moistureReceivedPercent: true, + aboveAverageRatioPercent: true, + mkSharePercent: true, + nonMkSharePercent: true, + shippingCost: true, + incomingOperationalCost: true, + afterArrivalOperationalCost: true, + agent: analysisInclude.agent, + profitShareScheme: analysisInclude.profitShareScheme, + lines: analysisInclude.lines, + lots: analysisInclude.lots, + analysis: analysisInclude.analysis + } + }); + + if (!refreshed) { + return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + } + if (refreshed.status !== "SUBMITTED") { + return NextResponse.json( + { message: "Purchase analysis hanya tersedia untuk purchase final" }, + { status: 404 } + ); + } + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PURCHASE_ANALYSIS_SAVED", + entityType: "PURCHASE_ANALYSIS", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Purchase analysis untuk purchase ${parsedId.toString()} disimpan`, + metadata: buildAuditChangeMetadata( + { + status: existingAnalysis?.status ?? null, + weight_buy: existingAnalysis?.weightBuy?.toNumber() ?? null, + weight_received: existingAnalysis?.weightReceived?.toNumber() ?? null, + weight_final: existingAnalysis?.weightFinal?.toNumber() ?? null, + moisture_buy_percent: existingAnalysis?.moistureBuyPercent?.toNumber() ?? null, + moisture_received_percent: existingAnalysis?.moistureReceivedPercent?.toNumber() ?? null, + moisture_final_percent: existingAnalysis?.moistureFinalPercent?.toNumber() ?? null, + above_average_ratio_percent: existingAnalysis?.aboveAverageRatioPercent?.toNumber() ?? null, + average_price: existingAnalysis?.averagePrice?.toNumber() ?? null, + modal_beli: existingAnalysis?.modalBeli?.toNumber() ?? null, + modal_masuk: existingAnalysis?.modalMasuk?.toNumber() ?? null, + modal_jual: existingAnalysis?.modalJual?.toNumber() ?? null, + modal_barang: existingAnalysis?.modalBarang?.toNumber() ?? null, + total_modal_beli: existingAnalysis?.totalModalBeli?.toNumber() ?? null, + total_modal_mal: existingAnalysis?.totalModalMal?.toNumber() ?? null, + market_reference_price: existingAnalysis?.marketReferencePrice?.toNumber() ?? null, + market_valuation_total: existingAnalysis?.marketValuationTotal?.toNumber() ?? null, + agent_profit_share_total: existingAnalysis?.agentProfitShareTotal?.toNumber() ?? null, + notes: existingAnalysis?.notes ?? null, + cost_entries: (existingAnalysis?.costEntries ?? []).map((entry) => ({ + cost_type: entry.costType, + description: entry.description, + amount: entry.amount.toNumber() + })) + }, + { + status: parsed.data.status, + weight_buy: parsed.data.weight_buy, + weight_received: parsed.data.weight_received, + weight_final: parsed.data.weight_final, + moisture_buy_percent: parsed.data.moisture_buy_percent, + moisture_received_percent: parsed.data.moisture_received_percent, + moisture_final_percent: parsed.data.moisture_final_percent, + above_average_ratio_percent: parsed.data.above_average_ratio_percent, + average_price: parsed.data.average_price, + modal_beli: parsed.data.modal_beli, + modal_masuk: parsed.data.modal_masuk, + modal_jual: parsed.data.modal_jual, + modal_barang: parsed.data.modal_barang, + total_modal_beli: parsed.data.total_modal_beli, + total_modal_mal: parsed.data.total_modal_mal, + market_reference_price: parsed.data.market_reference_price, + market_valuation_total: parsed.data.market_valuation_total, + agent_profit_share_total: parsed.data.agent_profit_share_total, + notes: parsed.data.notes || null, + cost_entries: parsed.data.cost_entries + } + ) + }); + + return NextResponse.json({ + data: serializePurchaseAnalysisDetail(refreshed) + }); + } catch (error) { + console.error(`Failed to save purchase analysis for ${parsedId.toString()}:`, error); + return NextResponse.json( + { message: "Gagal menyimpan analisis pembelian" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/purchase-analyses/route.ts b/src/app/api/v1/purchase-analyses/route.ts new file mode 100644 index 0000000..de01b77 --- /dev/null +++ b/src/app/api/v1/purchase-analyses/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; +import { serializePurchaseAnalysisListItem } from "@/features/purchase-analysis/lib/serialize-purchase-analysis"; + +const analysisInclude = { + agent: { + select: { + id: true, + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + }, + lines: { + select: { + subtotal: true, + qtyOrdered: true, + qtyReceived: true, + qtyAccepted: true, + unitPrice: true, + malUnitPrice: true, + moistureReceivedPercent: true, + purchaseMoisturePercent: true, + marketReferencePrice: true, + unit: { + select: { + code: true + } + } + } + }, + lots: { + select: { + originalQty: true, + finalMoisturePercent: true, + aboveAverageRatioPercent: true, + unit: { + select: { + code: true + } + } + } + }, + analysis: { + include: { + costEntries: true + } + } +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + const purchases = await prisma.purchase.findMany({ + where: { + status: "SUBMITTED", + purchaseType: "REGULAR" + }, + select: { + id: true, + purchaseNo: true, + purchaseDate: true, + status: true, + moistureBuyPercent: true, + moistureReceivedPercent: true, + aboveAverageRatioPercent: true, + mkSharePercent: true, + nonMkSharePercent: true, + shippingCost: true, + incomingOperationalCost: true, + afterArrivalOperationalCost: true, + agent: analysisInclude.agent, + profitShareScheme: analysisInclude.profitShareScheme, + lines: analysisInclude.lines, + lots: analysisInclude.lots, + analysis: analysisInclude.analysis + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: purchases.map(serializePurchaseAnalysisListItem) + }); + } catch (error) { + console.error("Failed to load purchase analyses:", error); + return NextResponse.json( + { message: "Gagal memuat analisis pembelian" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/purchase-realizations/[purchaseId]/route.ts b/src/app/api/v1/purchase-realizations/[purchaseId]/route.ts new file mode 100644 index 0000000..f3bb040 --- /dev/null +++ b/src/app/api/v1/purchase-realizations/[purchaseId]/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; + +import { serializePurchaseRealizationDetail } from "@/features/purchase-realization/lib/serialize-purchase-realization"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { + params: Promise<{ + purchaseId: string; + }>; +}; + +function parseId(rawId: string) { + try { + return BigInt(rawId); + } catch { + return null; + } +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).purchaseId); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid purchase id" }, { status: 400 }); + } + + const purchase = await prisma.purchase.findUnique({ + where: { id: parsedId }, + select: { + id: true, + purchaseNo: true, + purchaseType: true, + purchaseDate: true, + status: true, + agent: { + select: { + name: true + } + }, + buyoutSourceAgent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + name: true, + shareAgent: true + } + }, + realizationSummary: true, + realizationEntries: { + select: { + id: true, + eventType: true, + referenceType: true, + referenceId: true, + occurredAt: true, + qtyIn: true, + qtyOut: true, + qtyShrinkage: true, + amountCost: true, + amountRevenue: true, + amountExpense: true, + amountProfit: true, + agentAmount: true, + notes: true, + lot: { + select: { + id: true, + lotCode: true + } + } + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }] + } + } + }); + + if (!purchase) { + return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + } + + return NextResponse.json({ + data: serializePurchaseRealizationDetail(purchase) + }); +} diff --git a/src/app/api/v1/purchase-realizations/route.ts b/src/app/api/v1/purchase-realizations/route.ts new file mode 100644 index 0000000..87ce534 --- /dev/null +++ b/src/app/api/v1/purchase-realizations/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; + +import { serializePurchaseRealizationListItem } from "@/features/purchase-realization/lib/serialize-purchase-realization"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const purchases = await prisma.purchase.findMany({ + where: { + status: "SUBMITTED", + purchaseType: { + in: ["REGULAR", "OFFICE_BUYOUT"] + } + }, + select: { + id: true, + purchaseNo: true, + purchaseType: true, + purchaseDate: true, + status: true, + agent: { + select: { + name: true + } + }, + buyoutSourceAgent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + name: true, + shareAgent: true + } + }, + realizationSummary: true + }, + orderBy: [{ purchaseDate: "desc" }, { createdAt: "desc" }] + }); + + return NextResponse.json({ + data: purchases.map(serializePurchaseRealizationListItem) + }); +} diff --git a/src/app/api/v1/purchases/[id]/cancel/route.ts b/src/app/api/v1/purchases/[id]/cancel/route.ts new file mode 100644 index 0000000..af5c2d9 --- /dev/null +++ b/src/app/api/v1/purchases/[id]/cancel/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; + +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const purchase = await prisma.purchase.findUnique({ where: { id: parsedId } }); + if (!purchase) return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + const updated = await prisma.purchase.update({ + where: { id: parsedId }, + data: { status: "CANCELLED" } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PURCHASE_CANCELLED", + entityType: "PURCHASE", + entityId: updated.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Purchase ${updated.purchaseNo} dibatalkan`, + metadata: buildAuditChangeMetadata( + { status: purchase.status }, + { status: updated.status } + ) + }); + return NextResponse.json({ success: true, status: updated.status }); +} diff --git a/src/app/api/v1/purchases/[id]/route.ts b/src/app/api/v1/purchases/[id]/route.ts new file mode 100644 index 0000000..2ca9c04 --- /dev/null +++ b/src/app/api/v1/purchases/[id]/route.ts @@ -0,0 +1,342 @@ +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; + +import { buildPurchaseCostEntries } from "@/features/purchases/lib/build-purchase-cost-entries"; +import { serializePurchaseDetail } from "@/features/purchases/lib/serialize-purchase"; +import { isPurchasePayloadValidationError, parsePurchaseRequestPayload } from "@/features/purchases/lib/parse-purchase-request"; +import { type PurchaseInput } from "@/features/purchases/schemas/purchase.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +type ParsedLine = PurchaseInput["lines"][number]; + +function sumLineQty(lines: ParsedLine[]) { + return lines.reduce((sum, line) => sum + Number(line.qty_ordered || 0), 0); +} + +function toBigIntOrNull(raw: string | undefined | null) { + if (!raw) return null; + return BigInt(raw); +} + +function toDateOrThrow(rawDate: string, label: string): Date { + const parsed = new Date(rawDate); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`${label} tidak valid`); + } + return parsed; +} + +function buildLineCreateInput( + line: ParsedLine, + defaultWarehouseId: string, + defaultWarehouseLocationId: string | null, + fallbackGradeId: string +) { + const warehouseId = line.warehouse_id || defaultWarehouseId; + const warehouseLocationId = line.warehouse_location_id || defaultWarehouseLocationId; + return { + gradeId: BigInt(line.grade_id || fallbackGradeId), + qtyOrdered: line.qty_ordered, + purchaseMoisturePercent: null, + qtyReceived: line.qty_received, + qtyAccepted: line.qty_accepted, + qtyRejected: line.qty_rejected, + moistureReceivedPercent: null, + unitId: BigInt(line.unit_id), + unitPrice: line.unit_price, + unitCost: line.unit_cost, + malUnitPrice: line.mal_unit_price ?? null, + warehouseId: toBigIntOrNull(warehouseId), + warehouseLocationId: toBigIntOrNull(warehouseLocationId), + classificationStatus: line.classification_status, + subtotal: line.qty_ordered * line.unit_price, + notes: line.notes || null + }; +} + +const detailInclude = { + agent: true, + profitShareScheme: true, + courier: true, + receivedByEmployee: true, + analysis: { + include: { + costEntries: true + } + }, + lines: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } +} as const; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const purchase = await prisma.purchase.findUnique({ + where: { id: parsedId }, + include: detailInclude + }); + if (!purchase) return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + return NextResponse.json({ data: serializePurchaseDetail(purchase) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + let parsed: { payload: PurchaseInput; costProofFiles: Array<{ index: number; file: File }> }; + try { + parsed = await parsePurchaseRequestPayload(request); + } catch (error) { + if (isPurchasePayloadValidationError(error)) { + return NextResponse.json( + { message: error.message, errors: error.errors }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: "Request tidak valid" }, { status: 400 }); + } + const { payload, costProofFiles } = parsed; + const finalWeight = sumLineQty(payload.lines); + const costEntries = await buildPurchaseCostEntries(payload, costProofFiles); + const missingGradeLine = payload.lines.some((line) => !line.grade_id?.trim()); + + try { + const existing = await prisma.purchase.findUnique({ + where: { id: parsedId }, + include: { + lines: true + } + }); + if (!existing) { + return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + } + + const purchaseDate = toDateOrThrow(`${payload.purchase_date}T00:00:00.000Z`, "Tanggal pembelian"); + const receivedAt = toDateOrThrow(payload.received_at, "Waktu diterima"); + let defaultGradeId = ""; + if (missingGradeLine) { + const defaultGrade = await prisma.grade.findFirst({ + select: { id: true }, + orderBy: { id: "asc" } + }); + defaultGradeId = defaultGrade ? defaultGrade.id.toString() : ""; + if (!defaultGradeId) { + return NextResponse.json( + { message: "Grade tidak tersedia. Tambahkan grade di master data atau isi grade per line." }, + { status: 400 } + ); + } + } + + const purchase = await prisma.$transaction(async (tx) => { + await tx.purchaseLine.deleteMany({ where: { purchaseId: parsedId } }); + return tx.purchase.update({ + where: { id: parsedId }, + data: { + purchaseDate, + agentId: toBigIntOrNull(payload.agent_id), + profitShareSchemeId: toBigIntOrNull(payload.profit_share_scheme_id), + courierId: toBigIntOrNull(payload.courier_id), + receivedByEmployeeId: toBigIntOrNull(payload.received_by_employee_id), + receivedAt, + moistureBuyPercent: payload.moisture_buy_percent ?? null, + moistureReceivedPercent: payload.moisture_received_percent ?? null, + aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null, + mkSharePercent: payload.mk_share_percent ?? null, + nonMkSharePercent: payload.non_mk_share_percent ?? null, + shippingCost: null, + incomingOperationalCost: null, + afterArrivalOperationalCost: null, + notes: payload.notes || null, + analysis: { + upsert: { + create: { + status: "DRAFT", + weightBuy: payload.berat_beli ?? finalWeight, + weightReceived: payload.berat_masuk ?? finalWeight, + weightFinal: finalWeight, + moistureBuyPercent: payload.moisture_buy_percent ?? null, + moistureReceivedPercent: payload.moisture_received_percent ?? null, + moistureFinalPercent: payload.moisture_final_percent ?? null, + aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null, + averagePrice: payload.average_price ?? null, + modalBeli: payload.modal_beli ?? null, + modalMasuk: payload.modal_masuk ?? null, + modalJual: payload.modal_jual ?? null, + modalBarang: payload.modal_barang ?? null, + totalModalBeli: payload.total_modal_beli ?? null, + totalModalMal: payload.total_modal_mal ?? null, + marketReferencePrice: payload.market_reference_price ?? null, + costEntries: { + create: costEntries + } + }, + update: { + weightBuy: payload.berat_beli ?? finalWeight, + weightReceived: payload.berat_masuk ?? finalWeight, + weightFinal: finalWeight, + moistureBuyPercent: payload.moisture_buy_percent ?? null, + moistureReceivedPercent: payload.moisture_received_percent ?? null, + moistureFinalPercent: payload.moisture_final_percent ?? null, + aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null, + averagePrice: payload.average_price ?? null, + modalBeli: payload.modal_beli ?? null, + modalMasuk: payload.modal_masuk ?? null, + modalJual: payload.modal_jual ?? null, + modalBarang: payload.modal_barang ?? null, + totalModalBeli: payload.total_modal_beli ?? null, + totalModalMal: payload.total_modal_mal ?? null, + marketReferencePrice: payload.market_reference_price ?? null, + costEntries: { + deleteMany: {}, + create: costEntries + } + } + } + }, + lines: { + create: payload.lines.map((line) => + buildLineCreateInput( + line, + payload.warehouse_id || "", + payload.warehouse_location_id || null, + missingGradeLine + ? defaultGradeId + : line.grade_id || defaultGradeId + ) + ) + } + }, + include: detailInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PURCHASE_UPDATED", + entityType: "PURCHASE", + entityId: purchase.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Purchase ${purchase.purchaseNo} diubah`, + metadata: buildAuditChangeMetadata( + { + purchase_date: existing.purchaseDate, + notes: existing.notes, + status: existing.status, + line_count: existing.lines.length, + total_qty: existing.lines.reduce((sum, line) => sum + line.qtyOrdered.toNumber(), 0), + grand_total: existing.lines.reduce((sum, line) => sum + line.subtotal.toNumber(), 0) + }, + { + purchase_date: purchase.purchaseDate, + notes: purchase.notes, + status: purchase.status, + line_count: purchase.lines.length, + total_qty: purchase.lines.reduce((sum, line) => sum + line.qtyOrdered.toNumber(), 0), + grand_total: purchase.lines.reduce((sum, line) => sum + line.subtotal.toNumber(), 0) + } + ) + }); + + return NextResponse.json({ data: serializePurchaseDetail(purchase) }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError || error instanceof Prisma.PrismaClientValidationError) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.purchase.findUnique({ + where: { id: parsedId }, + include: { + _count: { + select: { + receipts: true, + lots: true + } + } + } + }); + + if (!existing) { + return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + } + + if (!["DRAFT", "CANCELLED"].includes(existing.status)) { + return NextResponse.json( + { message: "Hanya draft atau cancelled yang bisa dihapus" }, + { status: 409 } + ); + } + + if (existing._count.receipts > 0 || existing._count.lots > 0) { + return NextResponse.json( + { message: "Tidak dapat menghapus karena sudah memiliki relasi transaksi." }, + { status: 409 } + ); + } + + await prisma.purchase.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PURCHASE_DELETED", + entityType: "PURCHASE", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Purchase ${existing.purchaseNo} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + } + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return NextResponse.json( + { message: "Tidak dapat menghapus karena masih terkait transaksi lain." }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/purchases/[id]/submit/route.ts b/src/app/api/v1/purchases/[id]/submit/route.ts new file mode 100644 index 0000000..00ccd52 --- /dev/null +++ b/src/app/api/v1/purchases/[id]/submit/route.ts @@ -0,0 +1,236 @@ +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; + +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; +import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary"; +import { generateLotCode } from "@/features/receipts/lib/generate-lot-code"; + +type RouteContext = { params: Promise<{ id: string }> }; +type SubmitTx = Prisma.TransactionClient & { + lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation; + purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry; + purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary; +}; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const purchaseId = parsedId; + const purchase = await prisma.purchase.findUnique({ + where: { id: purchaseId }, + include: { + agent: { select: { id: true, name: true } }, + profitShareScheme: { select: { id: true, shareAgent: true } }, + lines: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } + } + }); + if (!purchase) return NextResponse.json({ message: "Purchase not found" }, { status: 404 }); + + if (purchase.status === "SUBMITTED") { + return NextResponse.json({ message: "Purchase sudah disubmit" }, { status: 409 }); + } + + if (!purchase.receivedByEmployeeId) { + return NextResponse.json({ message: "Penerima belum dipilih" }, { status: 400 }); + } + + if (!purchase.receivedAt) { + return NextResponse.json({ message: "Waktu diterima belum diisi" }, { status: 400 }); + } + + const receivedAt = purchase.receivedAt; + const sourceCode = purchase.agent?.name || purchase.purchaseNo; + const enteredQtyLines = purchase.lines.filter((line) => Number(line.qtyAccepted.toNumber()) > 0); + if (enteredQtyLines.length === 0) { + return NextResponse.json( + { message: "Tidak ada baris dengan qty yang masuk > 0 untuk generate lot" }, + { status: 400 } + ); + } + const missingWarehouseLine = enteredQtyLines.find((line) => !line.warehouseId); + if (missingWarehouseLine) { + return NextResponse.json( + { message: "Warehouse belum diisi pada salah satu baris pembelian" }, + { status: 400 } + ); + } + + const generated = await prisma.$transaction(async (rawTx) => { + const tx = rawTx as SubmitTx; + const baseLotCode = await generateLotCode(receivedAt, sourceCode); + const codeMatch = baseLotCode.match(/-(\d+)$/); + if (!codeMatch) { + throw new Error("Gagal generate kode lot"); + } + const lotPrefix = baseLotCode.replace(/-\d+$/, ""); + let lotSuffix = Number(codeMatch[1] ?? "0") || 1; + + const availableLotCount = await tx.inventoryLot.count({ where: { purchaseId } }); + if (availableLotCount > 0) { + throw new Error("Lot untuk purchase ini sudah pernah digenerate"); + } + + const lots = []; + for (const line of enteredQtyLines) { + if (!line.warehouseId) { + throw new Error("Warehouse belum diisi pada salah satu baris pembelian"); + } + const availableQty = Number(line.qtyAccepted.toNumber()); + const lotCode = `${lotPrefix}-${String(lotSuffix).padStart(3, "0")}`; + lotSuffix += 1; + + const lot = await tx.inventoryLot.create({ + data: { + lotCode, + sourceType: "PURCHASE", + sourceRefId: purchaseId, + purchaseId: purchase.id, + purchaseLineId: line.id, + gradeId: line.gradeId, + warehouseId: line.warehouseId, + warehouseLocationId: line.warehouseLocationId, + originalQty: availableQty, + availableQty, + unitId: line.unitId, + unitCost: line.unitCost, + receivedAt, + status: "ACTIVE", + qrCodeValue: lotCode, + barcodeValue: lotCode + } + }); + + const costTotalAllocated = Number( + (availableQty * line.unitCost.toNumber()).toFixed(2) + ); + const allocation = await tx.lotPurchaseAllocation.create({ + data: { + lotId: lot.id, + purchaseId: purchase.id, + purchaseLineId: line.id, + sourceType: "PURCHASE", + sourceRefId: purchase.id, + agentIdSnapshot: purchase.agentId, + profitShareSchemeIdSnapshot: purchase.profitShareSchemeId, + qtyAllocated: new Prisma.Decimal(availableQty), + costTotalAllocated: new Prisma.Decimal(costTotalAllocated), + unitCostSnapshot: line.unitCost + } + }); + + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: purchase.id, + lotId: lot.id, + allocationId: allocation.id, + eventType: "OPENING_COST", + referenceType: "PURCHASE", + referenceId: purchase.id, + occurredAt: receivedAt, + qtyIn: new Prisma.Decimal(availableQty), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(costTotalAllocated), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: purchase.profitShareScheme?.shareAgent ?? null, + agentAmount: new Prisma.Decimal(0), + notes: `Opening realization dari purchase line ${line.id.toString()}` + } + }); + + lots.push({ + id: lot.id.toString(), + lot_code: lot.lotCode + }); + } + + const updated = await tx.purchase.update({ + where: { id: purchaseId }, + data: { status: "SUBMITTED" } + }); + + await recalculatePurchaseRealizationSummary( + tx, + purchase.id, + purchase.profitShareScheme?.shareAgent.toNumber() ?? null + ); + + return { + purchase: updated, + lots + }; + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PURCHASE_SUBMITTED", + entityType: "PURCHASE", + entityId: generated.purchase.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Purchase ${purchase.purchaseNo} disubmit + lot dibuat`, + metadata: buildAuditChangeMetadata( + { status: purchase.status, lot_count: 0 }, + { status: generated.purchase.status, lot_count: generated.lots.length } + ) + }); + + if (generated.lots.length === 0) { + return NextResponse.json({ message: "Lot untuk purchase ini sudah pernah digenerate" }, { status: 409 }); + } + + return NextResponse.json( + { + success: true, + status: generated.purchase.status, + lot_count: generated.lots.length + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Kode lot duplikat saat submit. Coba lagi." }, + { status: 409 } + ); + } + if (error instanceof Error) { + console.error("PURCHASE_SUBMIT_ERROR", { + purchaseId: parsedId?.toString(), + message: error.message, + stack: error.stack + }); + return NextResponse.json({ message: error.message }, { status: 400 }); + } + console.error("PURCHASE_SUBMIT_ERROR", { + purchaseId: parsedId?.toString(), + message: "Unknown error" + }); + return NextResponse.json({ message: "Submit gagal karena kesalahan internal" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/purchases/office-buyout/bootstrap/route.ts b/src/app/api/v1/purchases/office-buyout/bootstrap/route.ts new file mode 100644 index 0000000..0720e0b --- /dev/null +++ b/src/app/api/v1/purchases/office-buyout/bootstrap/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const [agents, lots] = await Promise.all([ + prisma.agent.findMany({ + where: { + purchases: { + some: { + lots: { + some: { + status: "ACTIVE", + availableQty: { + gt: 0 + } + } + } + } + } + }, + orderBy: [{ name: "asc" }], + select: { + id: true, + code: true, + name: true + } + }), + prisma.inventoryLot.findMany({ + where: { + status: "ACTIVE", + availableQty: { + gt: 0 + }, + purchase: { + agentId: { + not: null + }, + purchaseType: "REGULAR" + } + }, + orderBy: [{ receivedAt: "desc" }], + include: { + purchase: { + select: { + agentId: true, + agent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseLine: { + select: { + malUnitPrice: true + } + }, + grade: { + select: { + name: true + } + }, + unit: { + select: { + code: true + } + }, + warehouse: { + select: { + name: true + } + }, + warehouseLocation: { + select: { + name: true + } + } + } + }) + ]); + + return NextResponse.json({ + data: { + agents: agents.map((agent) => ({ + id: agent.id.toString(), + code: agent.code, + name: agent.name + })), + lots: lots + .filter((lot) => lot.purchase?.agentId && lot.purchase?.agent?.name) + .map((lot) => ({ + id: lot.id.toString(), + agent_id: lot.purchase!.agentId!.toString(), + agent_name: lot.purchase!.agent!.name, + lot_code: lot.lotCode, + grade: lot.grade?.name ?? "-", + available_qty: lot.availableQty.toNumber(), + unit_code: lot.unit.code, + mal_unit_price: lot.purchaseLine?.malUnitPrice?.toNumber() ?? 0, + agent_share_percent: lot.purchase?.profitShareScheme?.shareAgent.toNumber() ?? 0, + warehouse: lot.warehouse.name, + location: lot.warehouseLocation?.name ?? null + })) + } + }); +} diff --git a/src/app/api/v1/purchases/office-buyout/route.ts b/src/app/api/v1/purchases/office-buyout/route.ts new file mode 100644 index 0000000..f8a2045 --- /dev/null +++ b/src/app/api/v1/purchases/office-buyout/route.ts @@ -0,0 +1,480 @@ +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; + +import { + AGENT_BALANCE_DIRECTIONS, + AGENT_BALANCE_SOURCES, + AGENT_BALANCE_TYPES, + createAgentBalanceMutation, + roundBalanceAmount +} from "@/features/agents/lib/balance-mutations"; +import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary"; +import { generatePurchaseNo } from "@/features/purchases/lib/generate-purchase-no"; +import { serializeOfficeBuyoutListItem } from "@/features/purchases/lib/serialize-office-buyout"; +import { officeBuyoutInputSchema } from "@/features/purchases/schemas/office-buyout.schema"; +import { generateLotCode } from "@/features/receipts/lib/generate-lot-code"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +function roundQty(value: number) { + return Number(value.toFixed(3)); +} + +function roundAmount(value: number) { + return Number(value.toFixed(2)); +} + +type OfficeBuyoutTx = Prisma.TransactionClient & { + lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation; + purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry; + purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary; +}; + +const officeBuyoutInclude = { + buyoutSourceAgent: { + select: { + id: true, + name: true + } + }, + lines: { + include: { + unit: { + select: { + code: true + } + }, + sourceLot: { + select: { + id: true, + lotCode: true, + grade: { + select: { + name: true + } + } + } + } + } + }, + lots: { + select: { + lotCode: true, + purchaseLineId: true + } + } +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const purchases = await prisma.purchase.findMany({ + where: { + purchaseType: "OFFICE_BUYOUT" + }, + include: officeBuyoutInclude, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: purchases.map(serializeOfficeBuyoutListItem) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = officeBuyoutInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const purchaseDate = new Date(`${parsed.data.purchase_date}T00:00:00.000Z`); + const sourceAgentId = parseBigInt(parsed.data.buyout_source_agent_id); + if (sourceAgentId === null || Number.isNaN(purchaseDate.getTime())) { + return NextResponse.json({ message: "Data referensi atau tanggal tidak valid" }, { status: 400 }); + } + + const sourceLotIds = parsed.data.lines.map((line) => parseBigInt(line.source_lot_id)); + if (sourceLotIds.some((id) => id === null)) { + return NextResponse.json({ message: "Lot sumber tidak valid" }, { status: 400 }); + } + + const [agent, sourceLots] = await Promise.all([ + prisma.agent.findUnique({ + where: { id: sourceAgentId }, + select: { + id: true, + name: true, + currentBalance: true + } + }), + prisma.inventoryLot.findMany({ + where: { + id: { + in: sourceLotIds as bigint[] + } + }, + include: { + purchase: { + select: { + id: true, + agentId: true, + profitShareSchemeId: true, + purchaseType: true, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseAllocations: true, + purchaseLine: { + select: { + malUnitPrice: true + } + }, + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + }) + ]); + + if (!agent) { + return NextResponse.json({ message: "Agen sumber tidak ditemukan" }, { status: 404 }); + } + + const sourceLotMap = new Map(sourceLots.map((lot) => [lot.id.toString(), lot])); + for (const line of parsed.data.lines) { + const sourceLot = sourceLotMap.get(line.source_lot_id); + if (!sourceLot) { + return NextResponse.json({ message: `Lot ${line.source_lot_id} tidak ditemukan` }, { status: 404 }); + } + if (sourceLot.purchase?.purchaseType !== "REGULAR" || sourceLot.purchase?.agentId !== sourceAgentId) { + return NextResponse.json({ message: `Lot ${sourceLot.lotCode} tidak berasal dari agen yang dipilih` }, { status: 422 }); + } + if (sourceLot.status !== "ACTIVE") { + return NextResponse.json({ message: `Lot ${sourceLot.lotCode} tidak aktif untuk buyout` }, { status: 422 }); + } + if (line.qty_buyout > sourceLot.availableQty.toNumber()) { + return NextResponse.json( + { + message: `Qty buyout untuk ${sourceLot.lotCode} melebihi stok tersedia ${sourceLot.availableQty.toNumber()}` + }, + { status: 422 } + ); + } + } + + const purchaseNo = await generatePurchaseNo(purchaseDate); + + const created = await prisma.$transaction(async (rawTx) => { + const tx = rawTx as OfficeBuyoutTx; + const purchase = await tx.purchase.create({ + data: { + purchaseNo, + purchaseType: "OFFICE_BUYOUT", + purchaseDate, + buyoutSourceAgentId: sourceAgentId, + receivedAt: purchaseDate, + status: "SUBMITTED", + notes: parsed.data.notes ?? null, + createdById: BigInt(auth.user.id) + } + }); + + const baseLotCode = await generateLotCode(purchaseDate, agent.name); + const codeMatch = baseLotCode.match(/-(\d+)$/); + if (!codeMatch) { + throw new Error("Gagal generate kode lot buyout"); + } + const lotPrefix = baseLotCode.replace(/-\d+$/, ""); + let lotSuffix = Number(codeMatch[1] ?? "0") || 1; + + let totalAgentCommission = 0; + const affectedSourcePurchaseIds = new Set(); + + for (const line of parsed.data.lines) { + const sourceLot = sourceLotMap.get(line.source_lot_id)!; + const qtyBuyout = roundQty(line.qty_buyout); + const buyoutUnitPrice = roundAmount(line.buyout_unit_price); + const malUnitPrice = sourceLot.purchaseLine?.malUnitPrice?.toNumber() ?? 0; + const agentSharePercent = sourceLot.purchase?.profitShareScheme?.shareAgent.toNumber() ?? 0; + const profitAmount = Math.max(0, roundAmount((buyoutUnitPrice - malUnitPrice) * qtyBuyout)); + const agentCommission = roundAmount(profitAmount * (agentSharePercent / 100)); + totalAgentCommission += agentCommission; + if (sourceLot.purchase?.id) { + affectedSourcePurchaseIds.add(sourceLot.purchase.id); + } + + const createdLine = await tx.purchaseLine.create({ + data: { + purchaseId: purchase.id, + gradeId: sourceLot.gradeId, + sourceLotId: sourceLot.id, + qtyOrdered: new Prisma.Decimal(qtyBuyout), + qtyReceived: new Prisma.Decimal(qtyBuyout), + qtyAccepted: new Prisma.Decimal(qtyBuyout), + qtyRejected: new Prisma.Decimal(0), + unitId: sourceLot.unitId, + unitPrice: new Prisma.Decimal(buyoutUnitPrice), + buyoutMalUnitPriceSnapshot: new Prisma.Decimal(roundAmount(malUnitPrice)), + buyoutAgentSharePercent: new Prisma.Decimal(roundAmount(agentSharePercent)), + buyoutProfitAmount: new Prisma.Decimal(profitAmount), + buyoutAgentCommission: new Prisma.Decimal(agentCommission), + unitCost: new Prisma.Decimal(buyoutUnitPrice), + malUnitPrice: sourceLot.purchaseLine?.malUnitPrice ?? null, + subtotal: new Prisma.Decimal(roundAmount(qtyBuyout * buyoutUnitPrice)), + classificationStatus: "FINAL", + warehouseId: sourceLot.warehouseId, + warehouseLocationId: sourceLot.warehouseLocationId, + notes: line.notes ?? null + } + }); + + const lotCode = `${lotPrefix}-${String(lotSuffix).padStart(3, "0")}`; + lotSuffix += 1; + + const createdLot = await tx.inventoryLot.create({ + data: { + lotCode, + parentLotId: sourceLot.id, + sourceType: "OFFICE_BUYOUT", + sourceRefId: purchase.id, + purchaseId: purchase.id, + purchaseLineId: createdLine.id, + gradeId: sourceLot.gradeId, + warehouseId: sourceLot.warehouseId, + warehouseLocationId: sourceLot.warehouseLocationId, + originalQty: new Prisma.Decimal(qtyBuyout), + availableQty: new Prisma.Decimal(qtyBuyout), + unitId: sourceLot.unitId, + unitCost: new Prisma.Decimal(buyoutUnitPrice), + receivedAt: purchaseDate, + status: "ACTIVE", + qrCodeValue: lotCode, + barcodeValue: lotCode, + notes: `Buyout kantor dari lot ${sourceLot.lotCode}` + } + }); + + const buyoutAmount = roundAmount(qtyBuyout * buyoutUnitPrice); + const buyoutAllocation = await tx.lotPurchaseAllocation.create({ + data: { + lotId: createdLot.id, + purchaseId: purchase.id, + purchaseLineId: createdLine.id, + sourceType: "OFFICE_BUYOUT", + sourceRefId: purchase.id, + agentIdSnapshot: null, + profitShareSchemeIdSnapshot: null, + qtyAllocated: new Prisma.Decimal(qtyBuyout), + costTotalAllocated: new Prisma.Decimal(buyoutAmount), + unitCostSnapshot: new Prisma.Decimal(buyoutUnitPrice), + notes: `Allocation buyout dari lot ${sourceLot.lotCode}` + } + }); + + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: purchase.id, + lotId: createdLot.id, + allocationId: buyoutAllocation.id, + eventType: "OPENING_COST", + referenceType: "PURCHASE", + referenceId: purchase.id, + occurredAt: purchaseDate, + qtyIn: new Prisma.Decimal(qtyBuyout), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(buyoutAmount), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Opening realization office buyout ${purchase.purchaseNo}` + } + }); + + const sourceAllocations = + sourceLot.purchaseAllocations.length > 0 + ? sourceLot.purchaseAllocations + : sourceLot.purchase?.id + ? [ + { + id: null, + lotId: sourceLot.id, + purchaseId: sourceLot.purchase.id, + purchaseLineId: sourceLot.purchaseLineId, + sourceType: "PURCHASE", + sourceRefId: sourceLot.purchase.id, + agentIdSnapshot: sourceLot.purchase.agentId ?? null, + profitShareSchemeIdSnapshot: sourceLot.purchase.profitShareSchemeId ?? null, + qtyAllocated: new Prisma.Decimal(sourceLot.availableQty), + costTotalAllocated: new Prisma.Decimal( + roundAmount(sourceLot.availableQty.toNumber() * sourceLot.unitCost.toNumber()) + ), + unitCostSnapshot: sourceLot.unitCost, + notes: null, + createdAt: new Date(), + updatedAt: new Date() + } + ] + : []; + + const sourceAvailableQty = sourceLot.availableQty.toNumber(); + for (const allocation of sourceAllocations) { + const allocationQty = allocation.qtyAllocated.toNumber(); + if (sourceAvailableQty <= 0 || allocationQty <= 0) continue; + const allocationRatio = allocationQty / sourceAvailableQty; + const transferredQty = roundQty(qtyBuyout * allocationRatio); + const transferredRevenue = roundAmount(buyoutAmount * allocationRatio); + const transferredCost = roundAmount(allocation.unitCostSnapshot.toNumber() * transferredQty); + const transferredProfit = Math.max(0, roundAmount(transferredRevenue - transferredCost)); + const transferredAgentAmount = roundAmount(transferredProfit * (agentSharePercent / 100)); + const remainingQty = roundQty(Math.max(0, allocationQty - transferredQty)); + const remainingCost = roundAmount( + Math.max(0, allocation.costTotalAllocated.toNumber() - transferredCost) + ); + + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: allocation.purchaseId, + lotId: sourceLot.id, + allocationId: allocation.id ?? undefined, + eventType: "OFFICE_BUYOUT_REVENUE", + referenceType: "PURCHASE", + referenceId: purchase.id, + occurredAt: purchaseDate, + qtyIn: new Prisma.Decimal(0), + qtyOut: new Prisma.Decimal(transferredQty), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(transferredRevenue), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(transferredProfit), + agentSharePercentSnapshot: + agentSharePercent > 0 ? new Prisma.Decimal(roundAmount(agentSharePercent)) : null, + agentAmount: new Prisma.Decimal(transferredAgentAmount), + notes: `Revenue office buyout ${purchase.purchaseNo} dari lot ${sourceLot.lotCode}` + } + }); + + if (allocation.id) { + await tx.lotPurchaseAllocation.update({ + where: { id: allocation.id }, + data: { + qtyAllocated: new Prisma.Decimal(remainingQty), + costTotalAllocated: new Prisma.Decimal(remainingCost) + } + }); + } + } + + const nextAvailable = roundQty(sourceLot.availableQty.toNumber() - qtyBuyout); + await tx.inventoryLot.update({ + where: { id: sourceLot.id }, + data: { + availableQty: new Prisma.Decimal(nextAvailable), + status: nextAvailable <= 0 ? "CLOSED" : "ACTIVE" + } + }); + } + + if (totalAgentCommission > 0) { + const nextBalance = roundBalanceAmount(agent.currentBalance.toNumber() + totalAgentCommission); + await tx.agent.update({ + where: { id: agent.id }, + data: { + currentBalance: new Prisma.Decimal(nextBalance) + } + }); + await createAgentBalanceMutation(tx, { + agentId: agent.id, + balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE, + direction: AGENT_BALANCE_DIRECTIONS.IN, + source: AGENT_BALANCE_SOURCES.OFFICE_BUYOUT_COMMISSION, + amount: totalAgentCommission, + balanceAfter: nextBalance, + effectiveDate: purchaseDate, + referenceType: "PURCHASE", + referenceId: purchase.id.toString(), + referenceNo: purchase.purchaseNo, + notes: `Komisi buyout kantor ${purchase.purchaseNo}`, + metadata: { + purchase_type: "OFFICE_BUYOUT", + source_agent_name: agent.name + } + }); + } + + await recalculatePurchaseRealizationSummary(tx, purchase.id, null); + for (const sourcePurchaseId of affectedSourcePurchaseIds) { + const sourcePurchase = await tx.purchase.findUnique({ + where: { id: sourcePurchaseId }, + select: { + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }); + await recalculatePurchaseRealizationSummary( + tx, + sourcePurchaseId, + sourcePurchase?.profitShareScheme?.shareAgent.toNumber() ?? null + ); + } + + return tx.purchase.findUniqueOrThrow({ + where: { id: purchase.id }, + include: officeBuyoutInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "OFFICE_BUYOUT_CREATED", + entityType: "PURCHASE", + entityId: created.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Buyout kantor ${created.purchaseNo} dibuat`, + metadata: { + purchase_type: "OFFICE_BUYOUT", + purchase_no: created.purchaseNo, + source_agent_name: agent.name, + line_count: created.lines.length + } + }); + + return NextResponse.json( + { + data: serializeOfficeBuyoutListItem(created) + }, + { status: 201 } + ); +} diff --git a/src/app/api/v1/purchases/route.ts b/src/app/api/v1/purchases/route.ts new file mode 100644 index 0000000..da22899 --- /dev/null +++ b/src/app/api/v1/purchases/route.ts @@ -0,0 +1,240 @@ +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; + +import { ensureSystemUser } from "@/features/purchases/lib/system-user"; +import { + serializePurchaseDetail, + serializePurchaseListItem +} from "@/features/purchases/lib/serialize-purchase"; +import { generatePurchaseNo } from "@/features/purchases/lib/generate-purchase-no"; +import { buildPurchaseCostEntries } from "@/features/purchases/lib/build-purchase-cost-entries"; +import { + isPurchasePayloadValidationError, + parsePurchaseRequestPayload +} from "@/features/purchases/lib/parse-purchase-request"; +import { type PurchaseInput } from "@/features/purchases/schemas/purchase.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +function toBigIntOrNull(raw: string | null | undefined): bigint | null { + if (!raw) return null; + return BigInt(raw); +} + +function toDateOrThrow(rawDate: string, label: string): Date { + const parsed = new Date(rawDate); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`${label} tidak valid`); + } + return parsed; +} + +type PurchaseLinePayload = PurchaseInput["lines"][number]; + +function sumLineQty(lines: PurchaseLinePayload[]) { + return lines.reduce((sum, line) => sum + Number(line.qty_ordered || 0), 0); +} + +function buildLineCreateInput( + line: PurchaseLinePayload, + defaultWarehouseId: string, + defaultWarehouseLocationId: string | null, + fallbackGradeId: string +) { + const gradeId = line.grade_id || fallbackGradeId; + const warehouseId = line.warehouse_id || defaultWarehouseId; + const warehouseLocationId = line.warehouse_location_id || defaultWarehouseLocationId; + return { + gradeId: gradeId ? BigInt(gradeId) : null, + qtyOrdered: line.qty_ordered, + purchaseMoisturePercent: null, + qtyReceived: line.qty_received, + qtyAccepted: line.qty_accepted, + qtyRejected: line.qty_rejected, + moistureReceivedPercent: null, + unitId: BigInt(line.unit_id), + unitPrice: line.unit_price, + unitCost: line.unit_cost, + malUnitPrice: line.mal_unit_price ?? null, + subtotal: line.qty_ordered * line.unit_price, + classificationStatus: line.classification_status, + warehouseId: toBigIntOrNull(warehouseId), + warehouseLocationId: toBigIntOrNull(warehouseLocationId), + notes: line.notes || null + }; +} + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const purchaseType = new URL(request.url).searchParams.get("purchase_type")?.trim().toUpperCase() || "REGULAR"; + const data = await prisma.purchase.findMany({ + where: { + purchaseType + }, + include: { + agent: { + select: { + id: true, + name: true + } + }, + lines: { + select: { + subtotal: true + } + } + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: data.map(serializePurchaseListItem) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + let parsed: { payload: PurchaseInput; costProofFiles: Array<{ index: number; file: File }> }; + try { + parsed = await parsePurchaseRequestPayload(request); + } catch (error) { + if (isPurchasePayloadValidationError(error)) { + return NextResponse.json( + { message: error.message, errors: error.errors }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: "Request tidak valid" }, { status: 400 }); + } + + const systemUser = await ensureSystemUser(); + const { payload, costProofFiles } = parsed; + const finalWeight = sumLineQty(payload.lines); + const purchaseDate = toDateOrThrow(`${payload.purchase_date}T00:00:00.000Z`, "Tanggal pembelian"); + const receivedAt = toDateOrThrow(payload.received_at, "Waktu diterima"); + const purchaseNo = await generatePurchaseNo(purchaseDate); + const costEntries = await buildPurchaseCostEntries(payload, costProofFiles); + const missingGradeLine = payload.lines.some((line) => !line.grade_id?.trim()); + let defaultGradeId = ""; + if (missingGradeLine) { + const defaultGrade = await prisma.grade.findFirst({ + select: { id: true }, + orderBy: { id: "asc" } + }); + defaultGradeId = defaultGrade ? defaultGrade.id.toString() : ""; + if (!defaultGradeId) { + return NextResponse.json( + { message: "Grade tidak tersedia. Tambahkan grade di master data atau isi grade per line." }, + { status: 400 } + ); + } + } + + try { + const purchase = await prisma.purchase.create({ + data: { + purchaseNo, + purchaseType: "REGULAR", + purchaseDate, + agentId: toBigIntOrNull(payload.agent_id), + profitShareSchemeId: toBigIntOrNull(payload.profit_share_scheme_id), + courierId: toBigIntOrNull(payload.courier_id), + receivedByEmployeeId: toBigIntOrNull(payload.received_by_employee_id), + receivedAt, + moistureBuyPercent: payload.moisture_buy_percent ?? null, + moistureReceivedPercent: payload.moisture_received_percent ?? null, + aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null, + mkSharePercent: payload.mk_share_percent ?? null, + nonMkSharePercent: payload.non_mk_share_percent ?? null, + shippingCost: null, + incomingOperationalCost: null, + afterArrivalOperationalCost: null, + status: "DRAFT", + notes: payload.notes || null, + createdById: systemUser.id, + lines: { + create: payload.lines.map((line) => + buildLineCreateInput( + line, + payload.warehouse_id || "", + payload.warehouse_location_id || null, + missingGradeLine + ? defaultGradeId + : line.grade_id || defaultGradeId + ) + ) + }, + analysis: { + create: { + status: "DRAFT", + weightBuy: payload.berat_beli ?? finalWeight, + weightReceived: payload.berat_masuk ?? finalWeight, + weightFinal: finalWeight, + moistureBuyPercent: payload.moisture_buy_percent ?? null, + moistureReceivedPercent: payload.moisture_received_percent ?? null, + moistureFinalPercent: payload.moisture_final_percent ?? null, + aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null, + averagePrice: payload.average_price ?? null, + modalBeli: payload.modal_beli ?? null, + modalMasuk: payload.modal_masuk ?? null, + modalJual: payload.modal_jual ?? null, + modalBarang: payload.modal_barang ?? null, + totalModalBeli: payload.total_modal_beli ?? null, + totalModalMal: payload.total_modal_mal ?? null, + marketReferencePrice: payload.market_reference_price ?? null, + costEntries: { + create: costEntries + } + } + } + }, + include: { + agent: true, + profitShareScheme: true, + courier: true, + receivedByEmployee: true, + lines: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + }, + analysis: { + include: { + costEntries: true + } + } + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "PURCHASE_CREATED", + entityType: "PURCHASE", + entityId: purchase.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Purchase ${purchase.purchaseNo} dibuat`, + metadata: { + purchase_no: purchase.purchaseNo, + line_count: purchase.lines.length + } + }); + + return NextResponse.json({ data: serializePurchaseDetail(purchase) }, { status: 201 }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError || error instanceof Prisma.PrismaClientValidationError) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + throw error; + } +} diff --git a/src/app/api/v1/receipts/[id]/generate-lots/route.ts b/src/app/api/v1/receipts/[id]/generate-lots/route.ts new file mode 100644 index 0000000..4e5604c --- /dev/null +++ b/src/app/api/v1/receipts/[id]/generate-lots/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; + +import { generateLotCode } from "@/features/receipts/lib/generate-lot-code"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const existingReceipt = await prisma.receipt.findUnique({ + where: { id: parsedId }, + include: { + purchase: true, + lines: true, + lots: true + } + }); + + if (!existingReceipt) { + return NextResponse.json({ message: "Receipt not found" }, { status: 404 }); + } + + if (existingReceipt.lots.length > 0) { + return NextResponse.json( + { message: "Lots already generated for this receipt" }, + { status: 409 } + ); + } + + const generated = await prisma.$transaction(async (tx) => { + const lots = []; + for (const line of existingReceipt.lines) { + if (Number(line.qtyAccepted) <= 0) { + continue; + } + + const lotCode = await generateLotCode( + existingReceipt.receiptDate, + existingReceipt.purchaseId.toString() + ); + + const lot = await tx.inventoryLot.create({ + data: { + lotCode, + sourceType: "RECEIPT", + sourceRefId: line.id, + purchaseId: existingReceipt.purchaseId, + purchaseLineId: line.purchaseLineId, + receiptId: existingReceipt.id, + receiptLineId: line.id, + gradeId: line.gradeId, + warehouseId: line.warehouseId, + warehouseLocationId: line.warehouseLocationId, + originalQty: line.qtyAccepted, + availableQty: line.qtyAccepted, + unitId: line.unitId, + unitCost: line.unitCost, + receivedAt: existingReceipt.receiptDate, + status: "ACTIVE", + qrCodeValue: lotCode, + barcodeValue: lotCode + } + }); + lots.push(lot); + } + + await tx.receipt.update({ + where: { id: existingReceipt.id }, + data: { status: "FINALIZED" } + }); + + return lots; + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "LOTS_GENERATED", + entityType: "RECEIPT", + entityId: existingReceipt.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `${generated.length} lot dibuat dari receipt ${existingReceipt.receiptNo}`, + metadata: { + receipt_no: existingReceipt.receiptNo, + lot_codes: generated.map((lot) => lot.lotCode) + } + }); + + return NextResponse.json({ + receipt_id: existingReceipt.id.toString(), + lots: generated.map((lot) => ({ + id: lot.id.toString(), + lot_code: lot.lotCode, + qr_code_value: lot.qrCodeValue + })) + }); +} diff --git a/src/app/api/v1/receipts/[id]/route.ts b/src/app/api/v1/receipts/[id]/route.ts new file mode 100644 index 0000000..1fd1951 --- /dev/null +++ b/src/app/api/v1/receipts/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; + +import { serializeReceiptDetail } from "@/features/receipts/lib/serialize-receipt"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +const receiptDetailInclude = { + purchase: true, + lines: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + }, + lots: true +} as const; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const receipt = await prisma.receipt.findUnique({ + where: { id: parsedId }, + include: receiptDetailInclude + }); + + if (!receipt) { + return NextResponse.json({ message: "Receipt not found" }, { status: 404 }); + } + + return NextResponse.json({ data: serializeReceiptDetail(receipt) }); +} diff --git a/src/app/api/v1/receipts/route.ts b/src/app/api/v1/receipts/route.ts new file mode 100644 index 0000000..e0729af --- /dev/null +++ b/src/app/api/v1/receipts/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; + +import { ensureSystemUser } from "@/features/purchases/lib/system-user"; +import { generateReceiptNo } from "@/features/receipts/lib/generate-receipt-no"; +import { + serializeReceiptDetail, + serializeReceiptListItem +} from "@/features/receipts/lib/serialize-receipt"; +import { receiptInputSchema } from "@/features/receipts/schemas/receipt.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +const receiptDetailInclude = { + purchase: true, + lines: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + }, + lots: true +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const data = await prisma.receipt.findMany({ + include: { + purchase: true, + lines: true, + lots: true + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: data.map(serializeReceiptListItem) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const parsed = receiptInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const systemUser = await ensureSystemUser(); + const receiptDate = new Date(`${parsed.data.receipt_date}T00:00:00.000Z`); + const receiptNo = await generateReceiptNo(receiptDate); + + const receipt = await prisma.receipt.create({ + data: { + receiptNo, + purchaseId: BigInt(parsed.data.purchase_id), + receiptDate, + status: "DRAFT", + notes: parsed.data.notes || null, + receivedById: systemUser.id, + lines: { + create: parsed.data.lines.map((line) => ({ + purchaseLineId: BigInt(line.purchase_line_id), + gradeId: line.grade_id ? BigInt(line.grade_id) : null, + qtyReceived: line.qty_received, + qtyAccepted: line.qty_accepted, + qtyRejected: line.qty_rejected, + unitId: BigInt(line.unit_id), + unitCost: line.unit_cost, + warehouseId: BigInt(line.warehouse_id), + warehouseLocationId: line.warehouse_location_id + ? BigInt(line.warehouse_location_id) + : null, + notes: line.notes || null + })) + } + }, + include: receiptDetailInclude + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "RECEIPT_CREATED", + entityType: "RECEIPT", + entityId: receipt.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Receipt ${receipt.receiptNo} dibuat`, + metadata: { + receipt_no: receipt.receiptNo, + purchase_no: receipt.purchase.purchaseNo, + line_count: receipt.lines.length + } + }); + + return NextResponse.json({ data: serializeReceiptDetail(receipt) }, { status: 201 }); +} diff --git a/src/app/api/v1/sales-jit/[id]/close/route.ts b/src/app/api/v1/sales-jit/[id]/close/route.ts new file mode 100644 index 0000000..3fdbeba --- /dev/null +++ b/src/app/api/v1/sales-jit/[id]/close/route.ts @@ -0,0 +1,243 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { + AGENT_BALANCE_DIRECTIONS, + AGENT_BALANCE_SOURCES, + AGENT_BALANCE_TYPES, + createAgentBalanceMutation +} from "@/features/agents/lib/balance-mutations"; +import { + isJitSalePayloadValidationError, + parseJitSaleCloseRequest +} from "@/features/sales-jit/lib/parse-jit-sale-request"; +import { serializeJitSaleDetail } from "@/features/sales-jit/lib/serialize-jit-sale"; +import { + storeRegularSaleReceipt, + validateRegularSaleReceiptFile +} from "@/features/sales-regular/lib/store-shipping-receipt"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +function roundAmount(value: number) { + return Number(value.toFixed(2)); +} + +function roundQty(value: number) { + return Number(value.toFixed(3)); +} + +function createAgentBalanceKey(agentId: bigint) { + return agentId.toString(); +} + +const jitSaleDetailInclude = { + buyer: true, + courier: true, + lines: { + include: { + grade: true, + agent: true, + profitShareScheme: true + } + } +} as const; + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + const parsedId = parseBigInt((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const { payload, shippingReceiptFile } = await parseJitSaleCloseRequest(request); + const closeDate = new Date(`${payload.close_date}T00:00:00.000Z`); + if (Number.isNaN(closeDate.getTime())) { + return NextResponse.json({ message: "Tanggal penyelesaian tidak valid" }, { status: 400 }); + } + + if (shippingReceiptFile) { + const fileError = validateRegularSaleReceiptFile(shippingReceiptFile); + if (fileError) { + return NextResponse.json({ message: fileError }, { status: 422 }); + } + } + + const existingSale = await prisma.jitSale.findUnique({ + where: { id: parsedId }, + include: jitSaleDetailInclude + }); + + if (!existingSale) { + return NextResponse.json({ message: "Penjualan just in time tidak ditemukan" }, { status: 404 }); + } + if (existingSale.status === "CLOSED") { + return NextResponse.json({ message: "Penjualan just in time sudah ditutup" }, { status: 409 }); + } + + const payloadLineIds = new Set(payload.lines.map((line) => line.line_id)); + if (payloadLineIds.size !== existingSale.lines.length || payload.lines.length !== existingSale.lines.length) { + return NextResponse.json( + { message: "Semua item penjualan wajib ditutup dalam satu proses" }, + { status: 422 } + ); + } + + const lineMap = new Map(existingSale.lines.map((line) => [line.id.toString(), line])); + const exchangeRate = existingSale.exchangeRate?.toNumber() ?? 1; + let totalNominalBuyer = 0; + let totalNominalCompany = 0; + let totalAgentCommission = 0; + const agentBalanceAdjustments = new Map(); + + for (const payloadLine of payload.lines) { + const line = lineMap.get(payloadLine.line_id); + if (!line) { + return NextResponse.json( + { message: `Line ${payloadLine.line_id} tidak terdaftar pada penjualan ini` }, + { status: 422 } + ); + } + + const qtyActualSold = line.qtyPlanned.toNumber(); + const nominalBuyer = qtyActualSold * payloadLine.selling_price_actual; + const nominalCompany = nominalBuyer * exchangeRate; + const actualSellingPriceCompany = payloadLine.selling_price_actual * exchangeRate; + const malUnitPrice = line.malUnitPrice.toNumber(); + const shareAgent = line.agentSharePercent?.toNumber() ?? 0; + const commissionBase = (actualSellingPriceCompany - malUnitPrice) * qtyActualSold; + const lineAgentCommission = roundAmount(commissionBase * (shareAgent / 100)); + + totalNominalBuyer += nominalBuyer; + totalNominalCompany += nominalCompany; + totalAgentCommission += lineAgentCommission; + + if (line.agentId && lineAgentCommission > 0) { + const key = createAgentBalanceKey(line.agentId); + const current = agentBalanceAdjustments.get(key); + if (current) { + current.amount = roundAmount(current.amount + lineAgentCommission); + } else { + agentBalanceAdjustments.set(key, { + agentId: line.agentId, + amount: lineAgentCommission + }); + } + } + } + + const shippingReceiptFileUrl = shippingReceiptFile + ? await storeRegularSaleReceipt(shippingReceiptFile) + : existingSale.shippingReceiptFileUrl; + + const updated = await prisma.$transaction(async (tx) => { + for (const payloadLine of payload.lines) { + const line = lineMap.get(payloadLine.line_id)!; + await tx.jitSaleLine.update({ + where: { id: line.id }, + data: { + qtyActualSold: new Prisma.Decimal(roundQty(line.qtyPlanned.toNumber())), + sellingPriceActual: new Prisma.Decimal(roundAmount(payloadLine.selling_price_actual)) + } + }); + } + + await tx.jitSale.update({ + where: { id: existingSale.id }, + data: { + closeDate, + shippingReceiptFileUrl, + totalNominalBuyer: new Prisma.Decimal(roundAmount(totalNominalBuyer)), + totalNominalCompany: new Prisma.Decimal(roundAmount(totalNominalCompany)), + totalAgentCommission: new Prisma.Decimal(roundAmount(totalAgentCommission)), + status: "CLOSED" + } + }); + + for (const adjustment of agentBalanceAdjustments.values()) { + const updatedAgent = await tx.agent.update({ + where: { id: adjustment.agentId }, + data: { + currentBalance: { + increment: new Prisma.Decimal(adjustment.amount) + } + }, + select: { + currentBalance: true + } + }); + + await createAgentBalanceMutation(tx, { + agentId: adjustment.agentId, + balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE, + direction: AGENT_BALANCE_DIRECTIONS.IN, + source: AGENT_BALANCE_SOURCES.JIT_SALE_COMMISSION, + amount: adjustment.amount, + balanceAfter: updatedAgent.currentBalance.toNumber(), + effectiveDate: closeDate, + referenceType: "JIT_SALE", + referenceId: existingSale.id.toString(), + referenceNo: existingSale.saleNo, + notes: `Komisi agent dari penjualan just in time ${existingSale.saleNo}` + }); + } + + return tx.jitSale.findUniqueOrThrow({ + where: { id: existingSale.id }, + include: jitSaleDetailInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "JIT_SALE_CLOSED", + entityType: "JIT_SALE", + entityId: updated.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Penjualan just in time ${updated.saleNo} ditutup`, + metadata: { + sale_no: updated.saleNo, + close_date: updated.closeDate?.toISOString().slice(0, 10) ?? null, + total_nominal_buyer: updated.totalNominalBuyer.toNumber(), + total_agent_commission: updated.totalAgentCommission.toNumber() + } + }); + + return NextResponse.json({ + data: serializeJitSaleDetail(updated) + }); + } catch (error) { + if (isJitSalePayloadValidationError(error)) { + return NextResponse.json( + { + message: error.message, + errors: error.errors + }, + { status: 422 } + ); + } + + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal menutup penjualan just in time" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/sales-jit/[id]/route.ts b/src/app/api/v1/sales-jit/[id]/route.ts new file mode 100644 index 0000000..937c14c --- /dev/null +++ b/src/app/api/v1/sales-jit/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; + +import { serializeJitSaleDetail } from "@/features/sales-jit/lib/serialize-jit-sale"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const jitSaleDetailInclude = { + buyer: true, + courier: true, + lines: { + include: { + grade: true, + agent: true, + profitShareScheme: true + } + } +} as const; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseBigInt((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const sale = await prisma.jitSale.findUnique({ + where: { id: parsedId }, + include: jitSaleDetailInclude + }); + + if (!sale) { + return NextResponse.json({ message: "Penjualan just in time tidak ditemukan" }, { status: 404 }); + } + + return NextResponse.json({ + data: serializeJitSaleDetail(sale) + }); +} diff --git a/src/app/api/v1/sales-jit/bootstrap/route.ts b/src/app/api/v1/sales-jit/bootstrap/route.ts new file mode 100644 index 0000000..68573bd --- /dev/null +++ b/src/app/api/v1/sales-jit/bootstrap/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +import { serializeAgent } from "@/features/agents/lib/serialize-agent"; +import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer"; +import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies"; +import { serializeCurrency } from "@/features/currencies/lib/serialize-currency"; +import { serializeCourier } from "@/features/couriers/lib/serialize-courier"; +import { serializeGrade } from "@/features/grades/lib/serialize-grade"; +import { serializeProfitShareScheme } from "@/features/profit-share-schemes/lib/serialize-profit-share-scheme"; +import { requireApiAccess } from "@/lib/authorization"; +import { getAppSettings } from "@/lib/app-settings"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + await ensureDefaultCurrencies(); + + const settings = await getAppSettings(); + const [buyers, couriers, currencies, grades, agents, profitShareSchemes] = await Promise.all([ + prisma.buyer.findMany({ + where: { status: "ACTIVE" }, + include: { contactPeople: true }, + orderBy: [{ name: "asc" }] + }), + prisma.courier.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }] + }), + prisma.currency.findMany({ + where: { + OR: [{ status: "ACTIVE" }, { code: settings.currency_code }] + }, + orderBy: [{ code: "asc" }] + }), + prisma.grade.findMany({ + where: { status: "ACTIVE" }, + include: { + buyPriceStandards: { + orderBy: [{ startDate: "desc" }] + }, + sellPriceStandards: { + orderBy: [{ startDate: "desc" }] + } + }, + orderBy: [{ name: "asc" }] + }), + prisma.agent.findMany({ + include: { + profitShareScheme: true, + bankAccounts: { + include: { + bank: true + } + } + }, + orderBy: [{ name: "asc" }] + }), + prisma.profitShareScheme.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }] + }) + ]); + + return NextResponse.json({ + data: { + default_company_currency_code: settings.currency_code, + buyers: buyers.map(serializeBuyer), + couriers: couriers.map(serializeCourier), + currencies: currencies.map(serializeCurrency), + grades: grades.map(serializeGrade), + agents: agents.map(serializeAgent), + profit_share_schemes: profitShareSchemes.map(serializeProfitShareScheme) + } + }); +} diff --git a/src/app/api/v1/sales-jit/route.ts b/src/app/api/v1/sales-jit/route.ts new file mode 100644 index 0000000..46bd465 --- /dev/null +++ b/src/app/api/v1/sales-jit/route.ts @@ -0,0 +1,306 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies"; +import { generateJitSaleNo } from "@/features/sales-jit/lib/generate-jit-sale-no"; +import { + isJitSalePayloadValidationError, + parseJitSaleCreateRequest +} from "@/features/sales-jit/lib/parse-jit-sale-request"; +import { + serializeJitSaleDetail, + serializeJitSaleListItem +} from "@/features/sales-jit/lib/serialize-jit-sale"; +import { + storeRegularSaleReceipt, + validateRegularSaleReceiptFile +} from "@/features/sales-regular/lib/store-shipping-receipt"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +function roundAmount(value: number) { + return Number(value.toFixed(2)); +} + +function roundQty(value: number) { + return Number(value.toFixed(3)); +} + +const jitSaleInclude = { + buyer: true, + lines: true +} as const; + +const jitSaleDetailInclude = { + buyer: true, + courier: true, + lines: { + include: { + grade: true, + agent: true, + profitShareScheme: true + } + } +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const sales = await prisma.jitSale.findMany({ + include: jitSaleInclude, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: sales.map(serializeJitSaleListItem) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + await ensureDefaultCurrencies(); + const { payload, shippingReceiptFile } = await parseJitSaleCreateRequest(request); + + const buyerId = parseBigInt(payload.buyer_id); + const courierId = payload.courier_id ? parseBigInt(payload.courier_id) : null; + const saleDate = new Date(`${payload.sale_date}T00:00:00.000Z`); + const buyerCurrencyCode = payload.buyer_currency_code.trim().toUpperCase(); + const companyCurrencyCode = payload.company_currency_code.trim().toUpperCase(); + + if ( + buyerId === null || + (payload.courier_id && courierId === null) || + Number.isNaN(saleDate.getTime()) + ) { + return NextResponse.json({ message: "Invalid reference id or date" }, { status: 400 }); + } + + const gradeIds = payload.lines.map((line) => parseBigInt(line.grade_id)); + const agentIds = payload.lines + .map((line) => (line.agent_id ? parseBigInt(line.agent_id) : null)) + .filter((value): value is bigint | null => value !== undefined); + const schemeIds = payload.lines + .map((line) => (line.profit_share_scheme_id ? parseBigInt(line.profit_share_scheme_id) : null)) + .filter((value): value is bigint | null => value !== undefined); + + if ( + gradeIds.some((id) => id === null) || + agentIds.some((id) => id === null) || + schemeIds.some((id) => id === null) + ) { + return NextResponse.json({ message: "Referensi line tidak valid" }, { status: 400 }); + } + + if (shippingReceiptFile) { + const fileError = validateRegularSaleReceiptFile(shippingReceiptFile); + if (fileError) { + return NextResponse.json({ message: fileError }, { status: 422 }); + } + } + + const [buyer, courier, currencies, grades, agents, profitShareSchemes] = await Promise.all([ + prisma.buyer.findUnique({ where: { id: buyerId } }), + courierId ? prisma.courier.findUnique({ where: { id: courierId } }) : Promise.resolve(null), + prisma.currency.findMany({ + where: { + code: { + in: [buyerCurrencyCode, companyCurrencyCode] + }, + status: "ACTIVE" + } + }), + prisma.grade.findMany({ + where: { + id: { in: gradeIds as bigint[] }, + status: "ACTIVE" + } + }), + prisma.agent.findMany({ + where: { + id: { + in: agentIds.filter((id): id is bigint => Boolean(id)) + } + } + }), + prisma.profitShareScheme.findMany({ + where: { + id: { + in: schemeIds.filter((id): id is bigint => Boolean(id)) + }, + status: "ACTIVE" + } + }) + ]); + + if (!buyer) { + return NextResponse.json({ message: "Buyer tidak ditemukan" }, { status: 404 }); + } + if (payload.courier_id && !courier) { + return NextResponse.json({ message: "Kurir tidak ditemukan" }, { status: 404 }); + } + if (currencies.length !== 2 && buyerCurrencyCode !== companyCurrencyCode) { + return NextResponse.json({ message: "Currency tidak valid" }, { status: 422 }); + } + if (currencies.length !== 1 && buyerCurrencyCode === companyCurrencyCode) { + return NextResponse.json({ message: "Currency tidak valid" }, { status: 422 }); + } + + const exchangeRate = + buyerCurrencyCode === companyCurrencyCode ? 1 : Number(payload.exchange_rate ?? 0); + const gradeMap = new Map(grades.map((grade) => [grade.id.toString(), grade])); + const agentMap = new Map(agents.map((agent) => [agent.id.toString(), agent])); + const schemeMap = new Map(profitShareSchemes.map((scheme) => [scheme.id.toString(), scheme])); + + let totalNominalBuyer = 0; + let totalNominalCompany = 0; + let totalAgentCommission = 0; + + for (const line of payload.lines) { + const grade = gradeMap.get(line.grade_id); + if (!grade) { + return NextResponse.json( + { message: `Grade ${line.grade_id} tidak ditemukan atau tidak aktif` }, + { status: 404 } + ); + } + + const agent = line.agent_id ? agentMap.get(line.agent_id) : null; + if (line.agent_id && !agent) { + return NextResponse.json( + { message: `Agent ${line.agent_id} tidak ditemukan atau tidak aktif` }, + { status: 404 } + ); + } + + const scheme = line.profit_share_scheme_id + ? schemeMap.get(line.profit_share_scheme_id) + : null; + if (line.profit_share_scheme_id && !scheme) { + return NextResponse.json( + { message: `Skema bagi hasil ${line.profit_share_scheme_id} tidak ditemukan atau tidak aktif` }, + { status: 404 } + ); + } + + const nominalBuyer = line.qty_planned * line.selling_price_planned; + const nominalCompany = nominalBuyer * exchangeRate; + const plannedSellingPriceCompany = line.selling_price_planned * exchangeRate; + const shareAgent = scheme?.shareAgent.toNumber() ?? 0; + const commissionBase = (plannedSellingPriceCompany - line.mal_unit_price) * line.qty_planned; + + totalNominalBuyer += nominalBuyer; + totalNominalCompany += nominalCompany; + totalAgentCommission += commissionBase * (shareAgent / 100); + } + + const shippingCostCompany = payload.shipping_cost_buyer * exchangeRate; + const shippingReceiptFileUrl = shippingReceiptFile + ? await storeRegularSaleReceipt(shippingReceiptFile) + : null; + const saleNo = await generateJitSaleNo(saleDate); + + const created = await prisma.$transaction(async (tx) => { + const sale = await tx.jitSale.create({ + data: { + saleNo, + saleDate, + buyerId, + buyerCurrencyCode, + companyCurrencyCode, + exchangeRate: + buyerCurrencyCode === companyCurrencyCode ? null : new Prisma.Decimal(exchangeRate), + courierId, + shippingCostBuyer: new Prisma.Decimal(roundAmount(payload.shipping_cost_buyer)), + shippingCostCompany: new Prisma.Decimal(roundAmount(shippingCostCompany)), + shippingReceiptFileUrl, + totalNominalBuyer: new Prisma.Decimal(roundAmount(totalNominalBuyer)), + totalNominalCompany: new Prisma.Decimal(roundAmount(totalNominalCompany)), + totalAgentCommission: new Prisma.Decimal(roundAmount(totalAgentCommission)), + notes: payload.notes ?? null, + createdById: BigInt(auth.user.id), + lines: { + create: payload.lines.map((line) => { + const agent = line.agent_id ? agentMap.get(line.agent_id) ?? null : null; + const scheme = line.profit_share_scheme_id + ? schemeMap.get(line.profit_share_scheme_id) ?? null + : null; + return { + gradeId: parseBigInt(line.grade_id)!, + qtyPlanned: new Prisma.Decimal(roundQty(line.qty_planned)), + malUnitPrice: new Prisma.Decimal(roundAmount(line.mal_unit_price)), + sellingPricePlanned: new Prisma.Decimal(roundAmount(line.selling_price_planned)), + agentId: agent?.id ?? null, + agentNameSnapshot: agent?.name ?? null, + profitShareSchemeId: scheme?.id ?? null, + profitShareSchemeName: scheme?.name ?? null, + agentSharePercent: scheme + ? new Prisma.Decimal(roundAmount(scheme.shareAgent.toNumber())) + : null, + notes: line.notes ?? null + }; + }) + } + } + }); + + return tx.jitSale.findUniqueOrThrow({ + where: { id: sale.id }, + include: jitSaleDetailInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "JIT_SALE_CREATED", + entityType: "JIT_SALE", + entityId: created.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Penjualan JIT ${created.saleNo} dibuat`, + metadata: { + sale_no: created.saleNo, + buyer_id: created.buyerId.toString(), + item_count: created.lines.length, + total_nominal_buyer: created.totalNominalBuyer.toNumber() + } + }); + + return NextResponse.json( + { + data: serializeJitSaleDetail(created) + }, + { status: 201 } + ); + } catch (error) { + if (isJitSalePayloadValidationError(error)) { + return NextResponse.json( + { + message: error.message, + errors: error.errors + }, + { status: 422 } + ); + } + + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal membuat penjualan just in time" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/sales-regular/[id]/close/route.ts b/src/app/api/v1/sales-regular/[id]/close/route.ts new file mode 100644 index 0000000..ecdf909 --- /dev/null +++ b/src/app/api/v1/sales-regular/[id]/close/route.ts @@ -0,0 +1,459 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { + AGENT_BALANCE_DIRECTIONS, + AGENT_BALANCE_SOURCES, + AGENT_BALANCE_TYPES, + createAgentBalanceMutation +} from "@/features/agents/lib/balance-mutations"; +import { + isRegularSalePayloadValidationError, + parseRegularSaleCloseRequest +} from "@/features/sales-regular/lib/parse-regular-sale-request"; +import { buildAllocationShares, getEffectiveLotAllocations, roundAmount as roundAllocationAmount } from "@/features/purchase-realization/lib/lot-allocation"; +import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary"; +import { serializeRegularSaleDetail } from "@/features/sales-regular/lib/serialize-regular-sale"; +import { + storeRegularSaleReceipt, + validateRegularSaleReceiptFile +} from "@/features/sales-regular/lib/store-shipping-receipt"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +function roundAmount(value: number) { + return Number(value.toFixed(2)); +} + +function roundQty(value: number) { + return Number(value.toFixed(3)); +} + +function createAgentBalanceKey(agentId: bigint) { + return agentId.toString(); +} + +const regularSaleDetailInclude = { + buyer: true, + courier: true, + lines: { + include: { + lot: { + include: { + purchase: { + select: { + id: true, + agentId: true, + profitShareSchemeId: true, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseAllocations: true, + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } + } + } +} as const; + +type RegularSaleTx = Prisma.TransactionClient & { + purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry; + purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary; + lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation; +}; + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + const parsedId = parseBigInt((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const { payload, shippingReceiptFile } = await parseRegularSaleCloseRequest(request); + const closeDate = new Date(`${payload.close_date}T00:00:00.000Z`); + if (Number.isNaN(closeDate.getTime())) { + return NextResponse.json({ message: "Tanggal penyelesaian tidak valid" }, { status: 400 }); + } + + if (shippingReceiptFile) { + const fileError = validateRegularSaleReceiptFile(shippingReceiptFile); + if (fileError) { + return NextResponse.json({ message: fileError }, { status: 422 }); + } + } + + const existingSale = await prisma.regularSale.findUnique({ + where: { id: parsedId }, + include: { + buyer: true, + courier: true, + lines: { + include: { + lot: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } + } + } + } + }); + + if (!existingSale) { + return NextResponse.json({ message: "Penjualan reguler tidak ditemukan" }, { status: 404 }); + } + if (existingSale.status === "CLOSED") { + return NextResponse.json({ message: "Penjualan reguler sudah ditutup" }, { status: 409 }); + } + + const payloadLineIds = new Set(payload.lines.map((line) => line.line_id)); + if (payloadLineIds.size !== existingSale.lines.length || payload.lines.length !== existingSale.lines.length) { + return NextResponse.json( + { message: "Semua item penjualan wajib ditutup dalam satu proses" }, + { status: 422 } + ); + } + + const lineMap = new Map(existingSale.lines.map((line) => [line.id.toString(), line])); + let totalNominalBuyer = 0; + let totalNominalCompany = 0; + let totalAgentCommission = 0; + const exchangeRate = existingSale.exchangeRate?.toNumber() ?? 1; + const agentBalanceAdjustments = new Map(); + + for (const payloadLine of payload.lines) { + const line = lineMap.get(payloadLine.line_id); + if (!line) { + return NextResponse.json( + { message: `Line ${payloadLine.line_id} tidak terdaftar pada penjualan ini` }, + { status: 422 } + ); + } + + const qtyPlanned = line.qtyPlanned.toNumber(); + const resolvedQty = payloadLine.qty_actual_sold + payloadLine.qty_returned; + if (resolvedQty > qtyPlanned) { + return NextResponse.json( + { + message: `Berat jual aktual + retur untuk ${line.lot.lotCode} tidak boleh melebihi berat jual` + }, + { status: 422 } + ); + } + + const qtyShrinkage = qtyPlanned - payloadLine.qty_actual_sold - payloadLine.qty_returned; + const nominalBuyer = payloadLine.qty_actual_sold * payloadLine.selling_price_actual; + const nominalCompany = nominalBuyer * exchangeRate; + const actualSellingPriceCompany = payloadLine.selling_price_actual * exchangeRate; + const malUnitPrice = line.malUnitPriceSnapshot?.toNumber() ?? 0; + const shareAgent = line.agentSharePercent?.toNumber() ?? 0; + const commissionBase = (actualSellingPriceCompany - malUnitPrice) * payloadLine.qty_actual_sold; + const lineAgentCommission = roundAmount(commissionBase * (shareAgent / 100)); + + totalNominalBuyer += nominalBuyer; + totalNominalCompany += nominalCompany; + totalAgentCommission += lineAgentCommission; + + if (line.agentId && lineAgentCommission > 0) { + const key = createAgentBalanceKey(line.agentId); + const current = agentBalanceAdjustments.get(key); + if (current) { + current.amount = roundAmount(current.amount + lineAgentCommission); + } else { + agentBalanceAdjustments.set(key, { + agentId: line.agentId, + amount: lineAgentCommission + }); + } + } + + if (qtyShrinkage < 0) { + return NextResponse.json( + { message: `Perhitungan susut untuk ${line.lot.lotCode} tidak valid` }, + { status: 422 } + ); + } + } + + const shippingReceiptFileUrl = shippingReceiptFile + ? await storeRegularSaleReceipt(shippingReceiptFile) + : existingSale.shippingReceiptFileUrl; + + const updated = await prisma.$transaction(async (rawTx) => { + const tx = rawTx as RegularSaleTx; + const affectedPurchaseIds = new Set(); + for (const payloadLine of payload.lines) { + const line = lineMap.get(payloadLine.line_id)!; + const qtyShrinkage = roundQty( + line.qtyPlanned.toNumber() - payloadLine.qty_actual_sold - payloadLine.qty_returned + ); + const nextAvailable = roundQty(line.lot.availableQty.toNumber() + payloadLine.qty_returned); + const nextShrinkage = roundQty(line.lot.shrinkageQty.toNumber() + qtyShrinkage); + + await tx.inventoryLot.update({ + where: { id: line.lotId }, + data: { + availableQty: new Prisma.Decimal(nextAvailable), + shrinkageQty: new Prisma.Decimal(nextShrinkage), + status: nextAvailable <= 0 ? "DEPLETED" : "ACTIVE" + } + }); + + await tx.regularSaleLine.update({ + where: { id: line.id }, + data: { + qtyActualSold: new Prisma.Decimal(roundQty(payloadLine.qty_actual_sold)), + qtyReturned: new Prisma.Decimal(roundQty(payloadLine.qty_returned)), + qtyShrinkage: new Prisma.Decimal(qtyShrinkage), + sellingPriceActual: new Prisma.Decimal(roundAmount(payloadLine.selling_price_actual)) + } + }); + + const allocations = getEffectiveLotAllocations(line.lot); + const allocationBaseQty = allocations.reduce( + (sum, allocation) => sum + allocation.qtyAllocated.toNumber(), + 0 + ); + const soldAndShrinkageQty = roundQty(payloadLine.qty_actual_sold + qtyShrinkage); + const shares = buildAllocationShares( + allocations, + allocationBaseQty, + soldAndShrinkageQty + ); + const lineRevenue = roundAmount(payloadLine.qty_actual_sold * payloadLine.selling_price_actual * exchangeRate); + + for (const share of shares) { + affectedPurchaseIds.add(share.allocation.purchaseId); + + const soldQtyShare = + soldAndShrinkageQty > 0 + ? roundQty(payloadLine.qty_actual_sold * (share.affectedAllocationQty / soldAndShrinkageQty)) + : 0; + const shrinkageQtyShare = + soldAndShrinkageQty > 0 + ? roundQty(qtyShrinkage * (share.affectedAllocationQty / soldAndShrinkageQty)) + : 0; + const revenueShare = roundAllocationAmount( + lineRevenue * (share.affectedAllocationQty / soldAndShrinkageQty || 0) + ); + + if (soldQtyShare > 0 || revenueShare > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: line.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "SALE_REVENUE", + referenceType: "REGULAR_SALE", + referenceId: existingSale.id, + occurredAt: closeDate, + qtyIn: new Prisma.Decimal(0), + qtyOut: new Prisma.Decimal(soldQtyShare), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(revenueShare), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Revenue regular sale ${existingSale.saleNo}` + } + }); + } + + if (payloadLine.qty_returned > 0) { + const returnQtyShare = roundQty( + payloadLine.qty_returned * (share.allocationQty / allocationBaseQty || 0) + ); + if (returnQtyShare > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: line.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "SALE_RETURN", + referenceType: "REGULAR_SALE", + referenceId: existingSale.id, + occurredAt: closeDate, + qtyIn: new Prisma.Decimal(returnQtyShare), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Return regular sale ${existingSale.saleNo}` + } + }); + } + } + + if (shrinkageQtyShare > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: line.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "SALE_SHRINKAGE", + referenceType: "REGULAR_SALE", + referenceId: existingSale.id, + occurredAt: closeDate, + qtyIn: new Prisma.Decimal(0), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(shrinkageQtyShare), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Susut regular sale ${existingSale.saleNo}` + } + }); + } + + if (share.allocation.id) { + await tx.lotPurchaseAllocation.update({ + where: { id: share.allocation.id }, + data: { + qtyAllocated: new Prisma.Decimal(share.remainingQty), + costTotalAllocated: new Prisma.Decimal( + roundAllocationAmount( + share.remainingQty * share.allocation.unitCostSnapshot.toNumber() + ) + ) + } + }); + } + } + } + + await tx.regularSale.update({ + where: { id: existingSale.id }, + data: { + closeDate, + shippingReceiptFileUrl, + totalNominalBuyer: new Prisma.Decimal(roundAmount(totalNominalBuyer)), + totalNominalCompany: new Prisma.Decimal(roundAmount(totalNominalCompany)), + totalAgentCommission: new Prisma.Decimal(roundAmount(totalAgentCommission)), + status: "CLOSED" + } + }); + + for (const adjustment of agentBalanceAdjustments.values()) { + const updatedAgent = await tx.agent.update({ + where: { id: adjustment.agentId }, + data: { + currentBalance: { + increment: new Prisma.Decimal(adjustment.amount) + } + }, + select: { + currentBalance: true + } + }); + + await createAgentBalanceMutation(tx, { + agentId: adjustment.agentId, + balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE, + direction: AGENT_BALANCE_DIRECTIONS.IN, + source: AGENT_BALANCE_SOURCES.REGULAR_SALE_COMMISSION, + amount: adjustment.amount, + balanceAfter: updatedAgent.currentBalance.toNumber(), + effectiveDate: closeDate, + referenceType: "REGULAR_SALE", + referenceId: existingSale.id.toString(), + referenceNo: existingSale.saleNo, + notes: `Komisi agent dari penjualan reguler ${existingSale.saleNo}` + }); + } + + for (const purchaseId of affectedPurchaseIds) { + const sourcePurchase = await tx.purchase.findUnique({ + where: { id: purchaseId }, + select: { + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }); + await recalculatePurchaseRealizationSummary( + tx, + purchaseId, + sourcePurchase?.profitShareScheme?.shareAgent.toNumber() ?? null + ); + } + + return tx.regularSale.findUniqueOrThrow({ + where: { id: existingSale.id }, + include: regularSaleDetailInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "REGULAR_SALE_CLOSED", + entityType: "REGULAR_SALE", + entityId: updated.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Penjualan reguler ${updated.saleNo} ditutup`, + metadata: { + sale_no: updated.saleNo, + close_date: updated.closeDate?.toISOString().slice(0, 10) ?? null, + total_nominal_buyer: updated.totalNominalBuyer.toNumber(), + total_agent_commission: updated.totalAgentCommission.toNumber() + } + }); + + return NextResponse.json({ + data: serializeRegularSaleDetail(updated) + }); + } catch (error) { + if (isRegularSalePayloadValidationError(error)) { + return NextResponse.json( + { + message: error.message, + errors: error.errors + }, + { status: 422 } + ); + } + + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal menutup penjualan reguler" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/sales-regular/[id]/route.ts b/src/app/api/v1/sales-regular/[id]/route.ts new file mode 100644 index 0000000..5a640da --- /dev/null +++ b/src/app/api/v1/sales-regular/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; + +import { serializeRegularSaleDetail } from "@/features/sales-regular/lib/serialize-regular-sale"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const regularSaleDetailInclude = { + buyer: true, + courier: true, + lines: { + include: { + lot: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } + } + } +} as const; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseBigInt((await context.params).id); + if (parsedId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const sale = await prisma.regularSale.findUnique({ + where: { id: parsedId }, + include: regularSaleDetailInclude + }); + + if (!sale) { + return NextResponse.json({ message: "Penjualan reguler tidak ditemukan" }, { status: 404 }); + } + + return NextResponse.json({ + data: serializeRegularSaleDetail(sale) + }); +} diff --git a/src/app/api/v1/sales-regular/bootstrap/route.ts b/src/app/api/v1/sales-regular/bootstrap/route.ts new file mode 100644 index 0000000..a90beae --- /dev/null +++ b/src/app/api/v1/sales-regular/bootstrap/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; + +import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies"; +import { serializeCurrency } from "@/features/currencies/lib/serialize-currency"; +import { serializeCourier } from "@/features/couriers/lib/serialize-courier"; +import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer"; +import { serializeRegularSaleCandidateLot } from "@/features/sales-regular/lib/serialize-regular-sale"; +import { requireApiAccess } from "@/lib/authorization"; +import { getAppSettings } from "@/lib/app-settings"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + await ensureDefaultCurrencies(); + + const settings = await getAppSettings(); + const [buyers, couriers, currencies, lots] = await Promise.all([ + prisma.buyer.findMany({ + where: { status: "ACTIVE" }, + include: { contactPeople: true }, + orderBy: [{ name: "asc" }] + }), + prisma.courier.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ name: "asc" }] + }), + prisma.currency.findMany({ + where: { + OR: [{ status: "ACTIVE" }, { code: settings.currency_code }] + }, + orderBy: [{ code: "asc" }] + }), + prisma.inventoryLot.findMany({ + where: { + status: "ACTIVE", + availableQty: { + gt: 0 + } + }, + include: { + purchase: { + select: { + agent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseLine: { + select: { + malUnitPrice: true + } + }, + grade: { select: { name: true } }, + unit: { select: { code: true } }, + warehouse: { select: { name: true } }, + warehouseLocation: { select: { name: true } } + }, + orderBy: [{ createdAt: "desc" }] + }) + ]); + + return NextResponse.json({ + data: { + default_company_currency_code: settings.currency_code, + buyers: buyers.map(serializeBuyer), + couriers: couriers.map(serializeCourier), + currencies: currencies.map(serializeCurrency), + lots: lots.map(serializeRegularSaleCandidateLot) + } + }); +} diff --git a/src/app/api/v1/sales-regular/route.ts b/src/app/api/v1/sales-regular/route.ts new file mode 100644 index 0000000..cf40cd1 --- /dev/null +++ b/src/app/api/v1/sales-regular/route.ts @@ -0,0 +1,307 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies"; +import { generateRegularSaleNo } from "@/features/sales-regular/lib/generate-regular-sale-no"; +import { + isRegularSalePayloadValidationError, + parseRegularSaleCreateRequest +} from "@/features/sales-regular/lib/parse-regular-sale-request"; +import { + serializeRegularSaleDetail, + serializeRegularSaleListItem +} from "@/features/sales-regular/lib/serialize-regular-sale"; +import { + storeRegularSaleReceipt, + validateRegularSaleReceiptFile +} from "@/features/sales-regular/lib/store-shipping-receipt"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +function roundAmount(value: number) { + return Number(value.toFixed(2)); +} + +function roundQty(value: number) { + return Number(value.toFixed(3)); +} + +const regularSaleInclude = { + buyer: true, + lines: true +} as const; + +const regularSaleDetailInclude = { + buyer: true, + courier: true, + lines: { + include: { + lot: { + include: { + grade: true, + unit: true, + warehouse: true, + warehouseLocation: true + } + } + } + } +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const sales = await prisma.regularSale.findMany({ + include: regularSaleInclude, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: sales.map(serializeRegularSaleListItem) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + await ensureDefaultCurrencies(); + const { payload, shippingReceiptFile } = await parseRegularSaleCreateRequest(request); + + const buyerId = parseBigInt(payload.buyer_id); + const courierId = payload.courier_id ? parseBigInt(payload.courier_id) : null; + const saleDate = new Date(`${payload.sale_date}T00:00:00.000Z`); + const buyerCurrencyCode = payload.buyer_currency_code.trim().toUpperCase(); + const companyCurrencyCode = payload.company_currency_code.trim().toUpperCase(); + + if ( + buyerId === null || + (payload.courier_id && courierId === null) || + Number.isNaN(saleDate.getTime()) + ) { + return NextResponse.json({ message: "Invalid reference id or date" }, { status: 400 }); + } + + const lotIds = payload.lines.map((line) => parseBigInt(line.lot_id)); + if (lotIds.some((id) => id === null)) { + return NextResponse.json({ message: "Lot tidak valid" }, { status: 400 }); + } + + if (shippingReceiptFile) { + const fileError = validateRegularSaleReceiptFile(shippingReceiptFile); + if (fileError) { + return NextResponse.json({ message: fileError }, { status: 422 }); + } + } + + const [buyer, courier, currencies, lots] = await Promise.all([ + prisma.buyer.findUnique({ where: { id: buyerId } }), + courierId ? prisma.courier.findUnique({ where: { id: courierId } }) : Promise.resolve(null), + prisma.currency.findMany({ + where: { + code: { + in: [buyerCurrencyCode, companyCurrencyCode] + }, + status: "ACTIVE" + } + }), + prisma.inventoryLot.findMany({ + where: { + id: { + in: lotIds as bigint[] + } + }, + include: { + purchase: { + select: { + agentId: true, + agent: { + select: { + name: true + } + }, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseLine: { + select: { + malUnitPrice: true + } + } + } + }) + ]); + + if (!buyer) { + return NextResponse.json({ message: "Buyer tidak ditemukan" }, { status: 404 }); + } + if (payload.courier_id && !courier) { + return NextResponse.json({ message: "Kurir tidak ditemukan" }, { status: 404 }); + } + if (currencies.length !== 2 && buyerCurrencyCode !== companyCurrencyCode) { + return NextResponse.json({ message: "Currency tidak valid" }, { status: 422 }); + } + if (currencies.length !== 1 && buyerCurrencyCode === companyCurrencyCode) { + return NextResponse.json({ message: "Currency tidak valid" }, { status: 422 }); + } + + const exchangeRate = + buyerCurrencyCode === companyCurrencyCode ? 1 : Number(payload.exchange_rate ?? 0); + const lotMap = new Map(lots.map((lot) => [lot.id.toString(), lot])); + + let totalNominalBuyer = 0; + let totalNominalCompany = 0; + let totalAgentCommission = 0; + + for (const line of payload.lines) { + const lot = lotMap.get(line.lot_id); + if (!lot) { + return NextResponse.json({ message: `Lot ${line.lot_id} tidak ditemukan` }, { status: 404 }); + } + if (lot.status !== "ACTIVE") { + return NextResponse.json( + { message: `Lot ${lot.lotCode} tidak aktif untuk penjualan reguler` }, + { status: 422 } + ); + } + if (line.qty_planned > lot.availableQty.toNumber()) { + return NextResponse.json( + { + message: `Berat jual untuk ${lot.lotCode} melebihi stok tersedia ${lot.availableQty.toNumber()}` + }, + { status: 422 } + ); + } + + const nominalBuyer = line.qty_planned * line.selling_price_planned; + const nominalCompany = nominalBuyer * exchangeRate; + const plannedSellingPriceCompany = line.selling_price_planned * exchangeRate; + const malUnitPrice = lot.purchaseLine?.malUnitPrice?.toNumber() ?? 0; + const shareAgent = lot.purchase?.profitShareScheme?.shareAgent.toNumber() ?? 0; + const commissionBase = (plannedSellingPriceCompany - malUnitPrice) * line.qty_planned; + + totalNominalBuyer += nominalBuyer; + totalNominalCompany += nominalCompany; + totalAgentCommission += commissionBase * (shareAgent / 100); + } + + const shippingCostCompany = payload.shipping_cost_buyer * exchangeRate; + const shippingReceiptFileUrl = shippingReceiptFile + ? await storeRegularSaleReceipt(shippingReceiptFile) + : null; + const saleNo = await generateRegularSaleNo(saleDate); + + const created = await prisma.$transaction(async (tx) => { + const sale = await tx.regularSale.create({ + data: { + saleNo, + saleDate, + buyerId, + buyerCurrencyCode, + companyCurrencyCode, + exchangeRate: + buyerCurrencyCode === companyCurrencyCode ? null : new Prisma.Decimal(exchangeRate), + courierId, + shippingCostBuyer: new Prisma.Decimal(roundAmount(payload.shipping_cost_buyer)), + shippingCostCompany: new Prisma.Decimal(roundAmount(shippingCostCompany)), + shippingReceiptFileUrl, + totalNominalBuyer: new Prisma.Decimal(roundAmount(totalNominalBuyer)), + totalNominalCompany: new Prisma.Decimal(roundAmount(totalNominalCompany)), + totalAgentCommission: new Prisma.Decimal(roundAmount(totalAgentCommission)), + notes: payload.notes ?? null, + createdById: BigInt(auth.user.id), + lines: { + create: payload.lines.map((line) => { + const lot = lotMap.get(line.lot_id)!; + return { + lotId: lot.id, + availableQtySnapshot: lot.availableQty, + malUnitPriceSnapshot: lot.purchaseLine?.malUnitPrice ?? null, + agentId: lot.purchase?.agentId ?? null, + agentNameSnapshot: lot.purchase?.agent?.name ?? null, + agentSharePercent: lot.purchase?.profitShareScheme?.shareAgent ?? null, + qtyPlanned: new Prisma.Decimal(roundQty(line.qty_planned)), + sellingPricePlanned: new Prisma.Decimal(roundAmount(line.selling_price_planned)), + notes: line.notes ?? null + }; + }) + } + } + }); + + for (const line of payload.lines) { + const lot = lotMap.get(line.lot_id)!; + const nextAvailable = roundQty(lot.availableQty.toNumber() - line.qty_planned); + await tx.inventoryLot.update({ + where: { id: lot.id }, + data: { + availableQty: new Prisma.Decimal(nextAvailable), + status: nextAvailable <= 0 ? "DEPLETED" : "ACTIVE" + } + }); + } + + return tx.regularSale.findUniqueOrThrow({ + where: { id: sale.id }, + include: regularSaleDetailInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "REGULAR_SALE_CREATED", + entityType: "REGULAR_SALE", + entityId: created.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Penjualan reguler ${created.saleNo} dibuat`, + metadata: { + sale_no: created.saleNo, + buyer_name: created.buyer.name, + line_count: created.lines.length, + buyer_currency_code: created.buyerCurrencyCode, + company_currency_code: created.companyCurrencyCode + } + }); + + return NextResponse.json( + { + data: serializeRegularSaleDetail(created) + }, + { status: 201 } + ); + } catch (error) { + if (isRegularSalePayloadValidationError(error)) { + return NextResponse.json( + { + message: error.message, + errors: error.errors + }, + { status: 422 } + ); + } + + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal menyimpan penjualan reguler" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/sales/[id]/detail/route.ts b/src/app/api/v1/sales/[id]/detail/route.ts new file mode 100644 index 0000000..02428e2 --- /dev/null +++ b/src/app/api/v1/sales/[id]/detail/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; + +import { serializeSales } from "@/features/sales/lib/serialize-sales"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseId(id: string) { + try { + return BigInt(id); + } catch { + return null; + } +} + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const sales = await prisma.sales.findUnique({ + where: { id: parsedId }, + include: { + bankAccounts: { include: { bank: true } }, + _count: { + select: { + consignments: true + } + } + } + }); + + if (!sales) { + return NextResponse.json({ message: "Sales not found" }, { status: 404 }); + } + + const [mutations, commissionCloseCount] = await Promise.all([ + prisma.salesCommissionMutation.findMany({ + where: { salesId: parsedId }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }] + }), + prisma.consignmentLine.count({ + where: { + status: "CLOSED", + salesCommission: { gt: 0 }, + consignment: { + salesId: parsedId + } + } + }) + ]); + + return NextResponse.json({ + data: { + ...serializeSales(sales), + stats: { + consignment_count: sales._count.consignments, + commission_close_count: commissionCloseCount, + history_count: mutations.length + }, + commission_history: mutations.map((item) => ({ + id: item.id.toString(), + source: item.source as "OPENING_BALANCE" | "MANUAL_ADJUSTMENT" | "CONSIGNMENT_COMMISSION", + direction: item.direction as "IN" | "OUT", + amount: item.amount.toNumber(), + balance_after: item.balanceAfter.toNumber(), + occurred_at: item.occurredAt.toISOString(), + effective_date: item.effectiveDate?.toISOString().slice(0, 10) ?? null, + reference_no: item.referenceNo, + description: + item.notes ?? + (item.source === "CONSIGNMENT_COMMISSION" + ? "Komisi sales dari titip jual" + : item.source === "MANUAL_ADJUSTMENT" + ? "Penyesuaian manual komisi sales" + : "Saldo pembuka komisi sales"), + notes: item.referenceType + ? `${item.referenceType}${item.referenceId ? ` · ${item.referenceId}` : ""}` + : null + })) + } + }); +} diff --git a/src/app/api/v1/sales/[id]/route.ts b/src/app/api/v1/sales/[id]/route.ts new file mode 100644 index 0000000..43f7639 --- /dev/null +++ b/src/app/api/v1/sales/[id]/route.ts @@ -0,0 +1,206 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeSales } from "@/features/sales/lib/serialize-sales"; +import { salesInputSchema } from "@/features/sales/schemas/sales.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const sales = await prisma.sales.findUnique({ + where: { id: parsedId }, + include: { + bankAccounts: { include: { bank: true } } + } + }); + + if (!sales) return NextResponse.json({ message: "Sales not found" }, { status: 404 }); + return NextResponse.json({ data: serializeSales(sales) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = salesInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.sales.findUnique({ + where: { id: parsedId }, + include: { + bankAccounts: { include: { bank: true } } + } + }); + + if (!existing) return NextResponse.json({ message: "Sales not found" }, { status: 404 }); + + const bankIds = parsed.data.bank_accounts.map((account) => BigInt(account.bank_id)); + + const banks = await prisma.bank.findMany({ + where: { id: { in: bankIds }, status: "ACTIVE" } + }); + + if (banks.length !== bankIds.length) { + return NextResponse.json( + { message: "Validasi gagal", errors: { bank_accounts: ["Semua rekening harus memilih bank aktif dari master"] } }, + { status: 400 } + ); + } + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "SEL", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => prisma.sales.count({ where: { code: { startsWith: "SEL" } } }), + exists: async (code) => (await prisma.sales.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const sales = await prisma.sales.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + identityType: parsed.data.identity_type, + identityNumber: parsed.data.identity_number, + mobilePhone: parsed.data.mobile_phone || null, + email: parsed.data.email || null, + address: parsed.data.address || null, + notes: parsed.data.notes || null, + joinDate: new Date(parsed.data.join_date), + bankAccounts: { + deleteMany: {}, + create: parsed.data.bank_accounts.map((account) => ({ + bankId: BigInt(account.bank_id), + accountNumber: account.account_number + })) + } + }, + include: { + bankAccounts: { include: { bank: true } } + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "SELLER_UPDATED", + entityType: "SELLER", + entityId: sales.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Sales ${sales.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + identity_type: existing.identityType, + identity_number: existing.identityNumber, + mobile_phone: existing.mobilePhone, + email: existing.email, + address: existing.address, + notes: existing.notes, + join_date: existing.joinDate.toISOString().slice(0, 10), + bank_accounts: existing.bankAccounts.map((account) => ({ + bank_id: account.bank.id.toString(), + account_number: account.accountNumber + })) + }, + { + code: sales.code, + name: sales.name, + identity_type: sales.identityType, + identity_number: sales.identityNumber, + mobile_phone: sales.mobilePhone, + email: sales.email, + address: sales.address, + notes: sales.notes, + join_date: sales.joinDate.toISOString().slice(0, 10), + bank_accounts: sales.bankAccounts.map((account) => ({ + bank_id: account.bank.id.toString(), + account_number: account.accountNumber + })) + } + ) + }); + + return NextResponse.json({ data: serializeSales(sales) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Sales not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { identity_number: ["Kode sales atau identitas sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.sales.findUnique({ where: { id: parsedId } }); + await prisma.sales.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "SELLER_DELETED", + entityType: "SELLER", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Sales ${existing?.code ?? parsedId.toString()} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Sales not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/sales/route.ts b/src/app/api/v1/sales/route.ts new file mode 100644 index 0000000..7589989 --- /dev/null +++ b/src/app/api/v1/sales/route.ts @@ -0,0 +1,114 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeSales } from "@/features/sales/lib/serialize-sales"; +import { salesInputSchema } from "@/features/sales/schemas/sales.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const data = await prisma.sales.findMany({ + include: { + bankAccounts: { + include: { + bank: true + } + } + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ data: data.map(serializeSales) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = salesInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const bankIds = parsed.data.bank_accounts.map((account) => BigInt(account.bank_id)); + + const banks = await prisma.bank.findMany({ + where: { id: { in: bankIds }, status: "ACTIVE" } + }); + + if (banks.length !== bankIds.length) { + return NextResponse.json( + { message: "Validasi gagal", errors: { bank_accounts: ["Semua rekening harus memilih bank aktif dari master"] } }, + { status: 400 } + ); + } + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "SEL", + requestedCode: parsed.data.code, + countExisting: () => prisma.sales.count({ where: { code: { startsWith: "SEL" } } }), + exists: async (code) => (await prisma.sales.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const sales = await prisma.sales.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + identityType: parsed.data.identity_type, + identityNumber: parsed.data.identity_number, + mobilePhone: parsed.data.mobile_phone || null, + email: parsed.data.email || null, + address: parsed.data.address || null, + notes: parsed.data.notes || null, + joinDate: new Date(parsed.data.join_date), + bankAccounts: { + create: parsed.data.bank_accounts.map((account) => ({ + bankId: BigInt(account.bank_id), + accountNumber: account.account_number + })) + } + }, + include: { + bankAccounts: { include: { bank: true } } + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "SELLER_CREATED", + entityType: "SELLER", + entityId: sales.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Sales ${sales.code} dibuat` + }); + + return NextResponse.json({ data: serializeSales(sales) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { identity_number: ["Kode sales atau identitas sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/settings/route.ts b/src/app/api/v1/settings/route.ts new file mode 100644 index 0000000..955c316 --- /dev/null +++ b/src/app/api/v1/settings/route.ts @@ -0,0 +1,248 @@ +import { NextResponse } from "next/server"; + +import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies"; +import { serializeBank } from "@/features/banks/lib/serialize-bank"; +import { serializeCurrency } from "@/features/currencies/lib/serialize-currency"; +import { buildSettingsDiff } from "@/features/settings/lib/settings-diff"; +import { appSettingsSchema } from "@/features/settings/schemas/settings.schema"; +import { + getAppSettings, + getAppSettingsRecord, + serializeAppSettings +} from "@/lib/app-settings"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { resetMailerTransport } from "@/lib/mailer"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + await ensureDefaultCurrencies(); + const settings = await getAppSettings(); + const [currencies, banks] = await Promise.all([ + prisma.currency.findMany({ + where: { + OR: [{ status: "ACTIVE" }, { code: settings.currency_code }] + }, + orderBy: [{ code: "asc" }] + }), + prisma.bank.findMany({ + where: { + OR: [ + { status: "ACTIVE" }, + { name: settings.company_bank_name }, + { + id: { + in: settings.company_bank_accounts.map((account) => BigInt(account.bank_id)) + } + } + ] + }, + orderBy: [{ code: "asc" }] + }) + ]); + + return NextResponse.json({ + data: settings, + options: { + currencies: currencies.map(serializeCurrency), + banks: banks.map(serializeBank) + } + }); +} + +export async function PUT(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + await ensureDefaultCurrencies(); + const parsed = appSettingsSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const beforeRecord = await getAppSettingsRecord(); + const before = serializeAppSettings(beforeRecord); + const normalizedCurrencyCode = parsed.data.currency_code.toUpperCase(); + const currency = await prisma.currency.findFirst({ + where: { + code: normalizedCurrencyCode, + status: "ACTIVE" + } + }); + + if (!currency) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + currency_code: ["Mata uang harus dipilih dari master currency yang aktif"] + } + }, + { status: 422 } + ); + } + + const companyBankAccounts = parsed.data.company_bank_accounts; + const companyBankIds = companyBankAccounts.map((account) => BigInt(account.bank_id)); + if (companyBankIds.length > 0) { + const banks = await prisma.bank.findMany({ + where: { + id: { in: companyBankIds }, + status: "ACTIVE" + } + }); + + if (banks.length !== companyBankIds.length) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: { + company_bank_accounts: ["Semua rekening kantor harus memilih bank aktif dari master"] + } + }, + { status: 422 } + ); + } + } + + const primaryCompanyBankAccount = companyBankAccounts[0] ?? null; + const primaryBank = + primaryCompanyBankAccount === null + ? null + : await prisma.bank.findUnique({ + where: { id: BigInt(primaryCompanyBankAccount.bank_id) } + }); + + const updated = await prisma.appSetting.upsert({ + where: { + singletonKey: "SYSTEM" + }, + update: { + companyName: parsed.data.company_name || null, + companyEmail: parsed.data.company_email || null, + companyPhone: parsed.data.company_phone || null, + companyBankName: primaryBank?.name ?? null, + companyBankAccountNumber: primaryCompanyBankAccount?.account_number || null, + companyAddress: parsed.data.company_address || null, + companyTimezone: parsed.data.company_timezone, + smtpHost: parsed.data.smtp_host || null, + smtpPort: parsed.data.smtp_port, + smtpSecure: parsed.data.smtp_secure, + smtpUser: parsed.data.smtp_user || null, + smtpPassword: + parsed.data.smtp_password === "" + ? beforeRecord?.smtpPassword ?? null + : parsed.data.smtp_password, + smtpFromName: parsed.data.smtp_from_name || null, + smtpFromEmail: parsed.data.smtp_from_email || null, + purchasePrefix: parsed.data.purchase_prefix.toUpperCase(), + receiptPrefix: parsed.data.receipt_prefix.toUpperCase(), + lotPrefix: parsed.data.lot_prefix.toUpperCase(), + adjustmentPrefix: parsed.data.adjustment_prefix.toUpperCase(), + transformationPrefix: parsed.data.transformation_prefix.toUpperCase(), + fundRequestPrefix: parsed.data.fund_request_prefix.toUpperCase(), + washingPrefix: parsed.data.washing_prefix.toUpperCase(), + regularSalePrefix: parsed.data.regular_sale_prefix.toUpperCase(), + jitSalePrefix: parsed.data.jit_sale_prefix.toUpperCase(), + consignmentPrefix: parsed.data.consignment_prefix.toUpperCase(), + defaultLocale: parsed.data.default_locale, + currencyCode: normalizedCurrencyCode, + dateFormat: parsed.data.date_format, + passwordMinLength: parsed.data.password_min_length, + sessionTimeoutMinutes: parsed.data.session_timeout_minutes, + requireEmailVerification: parsed.data.require_email_verification, + auditRetentionDays: parsed.data.audit_retention_days, + companyBankAccounts: { + deleteMany: {}, + create: companyBankAccounts.map((account) => ({ + bankId: BigInt(account.bank_id), + accountNumber: account.account_number + })) + } + }, + create: { + singletonKey: "SYSTEM", + companyName: parsed.data.company_name || null, + companyEmail: parsed.data.company_email || null, + companyPhone: parsed.data.company_phone || null, + companyBankName: primaryBank?.name ?? null, + companyBankAccountNumber: primaryCompanyBankAccount?.account_number || null, + companyAddress: parsed.data.company_address || null, + companyTimezone: parsed.data.company_timezone, + smtpHost: parsed.data.smtp_host || null, + smtpPort: parsed.data.smtp_port, + smtpSecure: parsed.data.smtp_secure, + smtpUser: parsed.data.smtp_user || null, + smtpPassword: parsed.data.smtp_password || null, + smtpFromName: parsed.data.smtp_from_name || null, + smtpFromEmail: parsed.data.smtp_from_email || null, + purchasePrefix: parsed.data.purchase_prefix.toUpperCase(), + receiptPrefix: parsed.data.receipt_prefix.toUpperCase(), + lotPrefix: parsed.data.lot_prefix.toUpperCase(), + adjustmentPrefix: parsed.data.adjustment_prefix.toUpperCase(), + transformationPrefix: parsed.data.transformation_prefix.toUpperCase(), + fundRequestPrefix: parsed.data.fund_request_prefix.toUpperCase(), + washingPrefix: parsed.data.washing_prefix.toUpperCase(), + regularSalePrefix: parsed.data.regular_sale_prefix.toUpperCase(), + jitSalePrefix: parsed.data.jit_sale_prefix.toUpperCase(), + consignmentPrefix: parsed.data.consignment_prefix.toUpperCase(), + defaultLocale: parsed.data.default_locale, + currencyCode: normalizedCurrencyCode, + dateFormat: parsed.data.date_format, + passwordMinLength: parsed.data.password_min_length, + sessionTimeoutMinutes: parsed.data.session_timeout_minutes, + requireEmailVerification: parsed.data.require_email_verification, + auditRetentionDays: parsed.data.audit_retention_days, + companyBankAccounts: { + create: companyBankAccounts.map((account) => ({ + bankId: BigInt(account.bank_id), + accountNumber: account.account_number + })) + } + } + }); + + resetMailerTransport(); + + const updatedRecord = await getAppSettingsRecord(); + const after = serializeAppSettings(updatedRecord); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "SETTINGS_UPDATED", + entityType: "SETTING", + entityId: updated.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: "Pengaturan sistem diperbarui", + metadata: { + before, + after, + changes: buildSettingsDiff(before, after) + } + }); + + return NextResponse.json({ + data: after, + message: "Pengaturan berhasil disimpan." + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal menyimpan pengaturan" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/stock-adjustments/route.ts b/src/app/api/v1/stock-adjustments/route.ts new file mode 100644 index 0000000..27380ab --- /dev/null +++ b/src/app/api/v1/stock-adjustments/route.ts @@ -0,0 +1,166 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { serializeStockAdjustment } from "@/features/stock-adjustments/lib/serialize-stock-adjustment"; +import { generateStockAdjustmentNo } from "@/features/stock-adjustments/lib/generate-stock-adjustment-no"; +import { stockAdjustmentSchema } from "@/features/stock-adjustments/schemas/stock-adjustment.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const stockAdjustmentInclude = { + lot: { + include: { + grade: { select: { name: true } }, + unit: { select: { code: true } } + } + }, + adjustmentReason: true, + createdBy: { + include: { + role: { select: { code: true } } + } + } +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const data = await prisma.stockAdjustment.findMany({ + include: stockAdjustmentInclude, + orderBy: [{ adjustmentDate: "desc" }, { createdAt: "desc" }] + }); + + return NextResponse.json({ + data: data.map(serializeStockAdjustment) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = stockAdjustmentSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const lotId = parseBigInt(parsed.data.lot_id); + const reasonId = parseBigInt(parsed.data.adjustment_reason_id); + const adjustmentDate = new Date(parsed.data.adjustment_date); + + if (lotId === null || reasonId === null || Number.isNaN(adjustmentDate.getTime())) { + return NextResponse.json({ message: "Invalid reference id or date" }, { status: 400 }); + } + + const [lot, reason] = await Promise.all([ + prisma.inventoryLot.findUnique({ where: { id: lotId } }), + prisma.adjustmentReason.findUnique({ where: { id: reasonId } }) + ]); + + if (!lot) { + return NextResponse.json({ message: "Lot tidak ditemukan" }, { status: 404 }); + } + if (!reason) { + return NextResponse.json({ message: "Adjustment reason tidak ditemukan" }, { status: 404 }); + } + + const before = lot.availableQty.toNumber(); + const after = Number((before + parsed.data.qty_change).toFixed(3)); + + if (after < 0) { + return NextResponse.json( + { message: `Qty adjustment membuat stok negatif. Available saat ini ${before}.` }, + { status: 422 } + ); + } + + const adjustmentNo = await generateStockAdjustmentNo(adjustmentDate); + const absChange = Math.abs(parsed.data.qty_change); + + const created = await prisma.$transaction(async (tx) => { + const adjustment = await tx.stockAdjustment.create({ + data: { + adjustmentNo, + lotId, + adjustmentReasonId: reasonId, + adjustmentDate, + qtyChange: new Prisma.Decimal(parsed.data.qty_change), + availableQtyBefore: lot.availableQty, + availableQtyAfter: new Prisma.Decimal(after), + notes: parsed.data.notes ?? null, + createdById: BigInt(auth.user.id) + } + }); + + const lotUpdate: { + availableQty: Prisma.Decimal; + status: string; + shrinkageQty?: Prisma.Decimal; + damagedQty?: Prisma.Decimal; + } = { + availableQty: new Prisma.Decimal(after), + status: after <= 0 ? "DEPLETED" : "ACTIVE" + }; + + if (parsed.data.qty_change < 0 && reason.category.toUpperCase() === "SHRINKAGE") { + lotUpdate.shrinkageQty = new Prisma.Decimal( + Number((lot.shrinkageQty.toNumber() + absChange).toFixed(3)) + ); + } + + if (parsed.data.qty_change < 0 && reason.category.toUpperCase() === "DAMAGE") { + lotUpdate.damagedQty = new Prisma.Decimal( + Number((lot.damagedQty.toNumber() + absChange).toFixed(3)) + ); + } + + await tx.inventoryLot.update({ + where: { id: lotId }, + data: lotUpdate + }); + + return tx.stockAdjustment.findUniqueOrThrow({ + where: { id: adjustment.id }, + include: stockAdjustmentInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "STOCK_ADJUSTMENT_CREATED", + entityType: "STOCK_ADJUSTMENT", + entityId: created.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Stock adjustment ${created.adjustmentNo} dibuat untuk ${created.lot.lotCode}`, + metadata: { + adjustment_no: created.adjustmentNo, + lot_code: created.lot.lotCode, + qty_change: created.qtyChange.toNumber() + } + }); + + return NextResponse.json( + { + data: serializeStockAdjustment(created) + }, + { status: 201 } + ); +} diff --git a/src/app/api/v1/units/[id]/route.ts b/src/app/api/v1/units/[id]/route.ts new file mode 100644 index 0000000..302fd2d --- /dev/null +++ b/src/app/api/v1/units/[id]/route.ts @@ -0,0 +1,126 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeUnit } from "@/features/units/lib/serialize-unit"; +import { unitInputSchema } from "@/features/units/schemas/unit.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { try { return BigInt(id); } catch { return null; } }; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const unit = await prisma.unit.findUnique({ where: { id: parsedId } }); + if (!unit) return NextResponse.json({ message: "Unit not found" }, { status: 404 }); + return NextResponse.json({ data: serializeUnit(unit) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const parsed = unitInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + try { + const existing = await prisma.unit.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Unit not found" }, { status: 404 }); + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "UNT", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => + prisma.unit.count({ where: { code: { startsWith: "UNT" } } }), + exists: async (code) => + (await prisma.unit.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + const unit = await prisma.unit.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "UNIT_UPDATED", + entityType: "UNIT", + entityId: unit.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Unit ${unit.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name + }, + { + code: unit.code, + name: unit.name + } + ) + }); + return NextResponse.json({ data: serializeUnit(unit) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Unit not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode unit sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + try { + const existing = await prisma.unit.findUnique({ where: { id: parsedId } }); + await prisma.unit.delete({ where: { id: parsedId } }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "UNIT_DELETED", + entityType: "UNIT", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Unit ${existing?.code ?? parsedId.toString()} dihapus` + }); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Unit not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/units/route.ts b/src/app/api/v1/units/route.ts new file mode 100644 index 0000000..610d46a --- /dev/null +++ b/src/app/api/v1/units/route.ts @@ -0,0 +1,70 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeUnit } from "@/features/units/lib/serialize-unit"; +import { unitInputSchema } from "@/features/units/schemas/unit.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const data = await prisma.unit.findMany({ orderBy: [{ createdAt: "desc" }] }); + return NextResponse.json({ data: data.map(serializeUnit) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const parsed = unitInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "UNT", + requestedCode: parsed.data.code, + countExisting: () => + prisma.unit.count({ where: { code: { startsWith: "UNT" } } }), + exists: async (code) => + (await prisma.unit.count({ where: { code } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + const unit = await prisma.unit.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "UNIT_CREATED", + entityType: "UNIT", + entityId: unit.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Unit ${unit.code} dibuat` + }); + return NextResponse.json({ data: serializeUnit(unit) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode unit sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/users/[id]/force-reset/route.ts b/src/app/api/v1/users/[id]/force-reset/route.ts new file mode 100644 index 0000000..0b4f93e --- /dev/null +++ b/src/app/api/v1/users/[id]/force-reset/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; + +import { sendPasswordResetEmail } from "@/features/auth/lib/auth-emails"; +import { buildPasswordResetUrl, issuePasswordResetToken } from "@/features/auth/lib/password-reset"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const id = parseBigInt((await context.params).id); + if (id === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user?.email) { + return NextResponse.json({ message: "User tidak memiliki email aktif" }, { status: 404 }); + } + + const { plainToken } = await issuePasswordResetToken(user.id); + await sendPasswordResetEmail({ + to: user.email, + name: user.name, + resetUrl: buildPasswordResetUrl(plainToken) + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "USER_FORCE_RESET_SENT", + entityType: "USER", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Force reset password dikirim ke ${user.email}` + }); + + return NextResponse.json({ + message: "Email force reset password berhasil dikirim." + }); +} diff --git a/src/app/api/v1/users/[id]/route.ts b/src/app/api/v1/users/[id]/route.ts new file mode 100644 index 0000000..6799794 --- /dev/null +++ b/src/app/api/v1/users/[id]/route.ts @@ -0,0 +1,200 @@ +import { NextResponse } from "next/server"; + +import { sendEmailVerificationEmail } from "@/features/auth/lib/auth-emails"; +import { + buildEmailVerificationUrl, + issueEmailVerificationToken +} from "@/features/auth/lib/email-verification"; +import { updateUserSchema } from "@/features/users/schemas/user.schema"; +import { serializeUser } from "@/features/users/lib/serialize-user"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const id = parseBigInt((await context.params).id); + if (id === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + try { + const parsed = updateUserSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const currentUser = await prisma.user.findUnique({ + where: { id }, + include: { role: true } + }); + + if (!currentUser) { + return NextResponse.json({ message: "User tidak ditemukan" }, { status: 404 }); + } + + const email = parsed.data.email.toLowerCase(); + const username = parsed.data.username.toLowerCase(); + + const [role, existingEmail, existingUsername] = await Promise.all([ + prisma.role.findUnique({ where: { code: parsed.data.role_code } }), + prisma.user.findFirst({ + where: { + email, + id: { not: id } + } + }), + prisma.user.findFirst({ + where: { + username, + id: { not: id } + } + }) + ]); + + if (!role) { + return NextResponse.json({ message: "Role tidak ditemukan" }, { status: 404 }); + } + if (existingEmail) { + return NextResponse.json({ message: "Email sudah dipakai" }, { status: 409 }); + } + if (existingUsername) { + return NextResponse.json({ message: "Username sudah dipakai" }, { status: 409 }); + } + + const emailChanged = currentUser.email !== email; + + const updatedUser = await prisma.user.update({ + where: { id }, + data: { + roleId: role.id, + name: parsed.data.name, + email, + username, + phone: parsed.data.phone || null, + status: parsed.data.status, + emailVerifiedAt: emailChanged ? null : currentUser.emailVerifiedAt + }, + include: { + role: true + } + }); + + if (emailChanged) { + await prisma.emailVerificationToken.deleteMany({ + where: { userId: id } + }); + + const { plainToken } = await issueEmailVerificationToken(updatedUser.id); + await sendEmailVerificationEmail({ + to: updatedUser.email!, + name: updatedUser.name, + verifyUrl: buildEmailVerificationUrl(plainToken) + }); + } + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "USER_UPDATED", + entityType: "USER", + entityId: updatedUser.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: emailChanged + ? `User ${updatedUser.email ?? updatedUser.username ?? updatedUser.name} diperbarui dan verifikasi email direset` + : `User ${updatedUser.email ?? updatedUser.username ?? updatedUser.name} diperbarui`, + metadata: { + before: { + name: currentUser.name, + email: currentUser.email, + username: currentUser.username, + phone: currentUser.phone, + status: currentUser.status, + role_code: currentUser.role.code, + email_verified_at: currentUser.emailVerifiedAt?.toISOString() ?? null + }, + after: { + name: updatedUser.name, + email: updatedUser.email, + username: updatedUser.username, + phone: updatedUser.phone, + status: updatedUser.status, + role_code: updatedUser.role.code, + email_verified_at: updatedUser.emailVerifiedAt?.toISOString() ?? null + } + } + }); + + return NextResponse.json({ + data: serializeUser(updatedUser), + message: emailChanged + ? "User berhasil diperbarui. Email berubah, verifikasi ulang sudah dikirim." + : "User berhasil diperbarui." + }); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal memperbarui user" + }, + { status: 500 } + ); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const id = parseBigInt((await context.params).id); + if (id === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + if (id.toString() === auth.user.id) { + return NextResponse.json({ message: "Anda tidak bisa menghapus akun Anda sendiri." }, { status: 422 }); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) { + return NextResponse.json({ message: "User tidak ditemukan" }, { status: 404 }); + } + + try { + await prisma.user.delete({ where: { id } }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "USER_DELETED", + entityType: "USER", + entityId: id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `User ${user.email ?? user.username ?? user.name} dihapus` + }); + return NextResponse.json({ message: "User berhasil dihapus" }); + } catch { + return NextResponse.json( + { message: "User tidak bisa dihapus karena sudah terhubung ke data transaksi." }, + { status: 409 } + ); + } +} diff --git a/src/app/api/v1/users/[id]/send-verification/route.ts b/src/app/api/v1/users/[id]/send-verification/route.ts new file mode 100644 index 0000000..53c753a --- /dev/null +++ b/src/app/api/v1/users/[id]/send-verification/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; + +import { sendEmailVerificationEmail } from "@/features/auth/lib/auth-emails"; +import { + buildEmailVerificationUrl, + issueEmailVerificationToken +} from "@/features/auth/lib/email-verification"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const id = parseBigInt((await context.params).id); + if (id === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user?.email) { + return NextResponse.json({ message: "User tidak memiliki email aktif" }, { status: 404 }); + } + + if (user.emailVerifiedAt) { + return NextResponse.json({ message: "Email user sudah diverifikasi." }); + } + + const { plainToken } = await issueEmailVerificationToken(user.id); + await sendEmailVerificationEmail({ + to: user.email, + name: user.name, + verifyUrl: buildEmailVerificationUrl(plainToken) + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "USER_VERIFICATION_SENT", + entityType: "USER", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Email verifikasi dikirim ke ${user.email}` + }); + + return NextResponse.json({ + message: "Email verifikasi berhasil dikirim." + }); +} diff --git a/src/app/api/v1/users/route.ts b/src/app/api/v1/users/route.ts new file mode 100644 index 0000000..42e26fd --- /dev/null +++ b/src/app/api/v1/users/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; + +import { sendEmailVerificationEmail } from "@/features/auth/lib/auth-emails"; +import { + buildEmailVerificationUrl, + issueEmailVerificationToken +} from "@/features/auth/lib/email-verification"; +import { createUserSchema } from "@/features/users/schemas/user.schema"; +import { serializeUser } from "@/features/users/lib/serialize-user"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { hashPassword } from "@/lib/auth"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const users = await prisma.user.findMany({ + include: { + role: true + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ + data: users.map(serializeUser) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + try { + const parsed = createUserSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const email = parsed.data.email.toLowerCase(); + const username = parsed.data.username.toLowerCase(); + + const [role, existingEmail, existingUsername] = await Promise.all([ + prisma.role.findUnique({ where: { code: parsed.data.role_code } }), + prisma.user.findFirst({ where: { email } }), + prisma.user.findFirst({ where: { username } }) + ]); + + if (!role) { + return NextResponse.json({ message: "Role tidak ditemukan" }, { status: 404 }); + } + if (existingEmail) { + return NextResponse.json({ message: "Email sudah dipakai" }, { status: 409 }); + } + if (existingUsername) { + return NextResponse.json({ message: "Username sudah dipakai" }, { status: 409 }); + } + + const user = await prisma.user.create({ + data: { + roleId: role.id, + name: parsed.data.name, + email, + username, + phone: parsed.data.phone || null, + passwordHash: hashPassword(parsed.data.password), + emailVerifiedAt: null, + status: parsed.data.status + }, + include: { + role: true + } + }); + + if (user.email) { + const { plainToken } = await issueEmailVerificationToken(user.id); + await sendEmailVerificationEmail({ + to: user.email, + name: user.name, + verifyUrl: buildEmailVerificationUrl(plainToken) + }); + } + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "USER_CREATED", + entityType: "USER", + entityId: user.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `User ${user.email ?? user.username ?? user.name} dibuat`, + metadata: { + role: user.role.code, + email: user.email, + verified: false + } + }); + + return NextResponse.json( + { + data: serializeUser(user) + }, + { status: 201 } + ); + } catch (error) { + return NextResponse.json( + { + message: error instanceof Error ? error.message : "Gagal membuat user" + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/warehouse-locations/[id]/route.ts b/src/app/api/v1/warehouse-locations/[id]/route.ts new file mode 100644 index 0000000..99a0f61 --- /dev/null +++ b/src/app/api/v1/warehouse-locations/[id]/route.ts @@ -0,0 +1,151 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeWarehouseLocation } from "@/features/warehouse-locations/lib/serialize-warehouse-location"; +import { warehouseLocationInputSchema } from "@/features/warehouse-locations/schemas/warehouse-location.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const location = await prisma.warehouseLocation.findUnique({ + where: { id: parsedId }, + include: { warehouse: { select: { name: true } } } + }); + if (!location) return NextResponse.json({ message: "Warehouse location not found" }, { status: 404 }); + return NextResponse.json({ data: serializeWarehouseLocation(location) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const parsed = warehouseLocationInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + try { + const existing = await prisma.warehouseLocation.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Warehouse location not found" }, { status: 404 }); + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "LOC", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => + prisma.warehouseLocation.count({ where: { code: { startsWith: "LOC" } } }), + exists: async (code) => + (await prisma.warehouseLocation.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + const location = await prisma.warehouseLocation.update({ + where: { id: parsedId }, + data: { + warehouseId: BigInt(parsed.data.warehouse_id), + code: resolvedCode.code, + name: parsed.data.name, + locationType: parsed.data.location_type || null, + status: parsed.data.status + }, + include: { warehouse: { select: { name: true } } } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WAREHOUSE_LOCATION_UPDATED", + entityType: "WAREHOUSE_LOCATION", + entityId: location.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Warehouse location ${location.code} diubah`, + metadata: buildAuditChangeMetadata( + { + warehouse_id: existing.warehouseId, + code: existing.code, + name: existing.name, + location_type: existing.locationType, + status: existing.status + }, + { + warehouse_id: location.warehouseId, + code: location.code, + name: location.name, + location_type: location.locationType, + status: location.status + } + ) + }); + return NextResponse.json({ data: serializeWarehouseLocation(location) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Warehouse location not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode lokasi sudah dipakai di gudang ini"] } }, + { status: 409 } + ); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2003") { + return NextResponse.json( + { message: "Validasi gagal", errors: { warehouse_id: ["Gudang tidak valid"] } }, + { status: 400 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + try { + const existing = await prisma.warehouseLocation.findUnique({ where: { id: parsedId } }); + await prisma.warehouseLocation.delete({ where: { id: parsedId } }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WAREHOUSE_LOCATION_DELETED", + entityType: "WAREHOUSE_LOCATION", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Warehouse location ${existing?.code ?? parsedId.toString()} dihapus` + }); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Warehouse location not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/warehouse-locations/route.ts b/src/app/api/v1/warehouse-locations/route.ts new file mode 100644 index 0000000..937caae --- /dev/null +++ b/src/app/api/v1/warehouse-locations/route.ts @@ -0,0 +1,84 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeWarehouseLocation } from "@/features/warehouse-locations/lib/serialize-warehouse-location"; +import { warehouseLocationInputSchema } from "@/features/warehouse-locations/schemas/warehouse-location.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const data = await prisma.warehouseLocation.findMany({ + include: { warehouse: { select: { name: true } } }, + orderBy: [{ createdAt: "desc" }] + }); + return NextResponse.json({ data: data.map(serializeWarehouseLocation) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const parsed = warehouseLocationInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "LOC", + requestedCode: parsed.data.code, + countExisting: () => + prisma.warehouseLocation.count({ where: { code: { startsWith: "LOC" } } }), + exists: async (code) => + (await prisma.warehouseLocation.count({ where: { code } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + const location = await prisma.warehouseLocation.create({ + data: { + warehouseId: BigInt(parsed.data.warehouse_id), + code: resolvedCode.code, + name: parsed.data.name, + locationType: parsed.data.location_type || null, + status: parsed.data.status + }, + include: { warehouse: { select: { name: true } } } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WAREHOUSE_LOCATION_CREATED", + entityType: "WAREHOUSE_LOCATION", + entityId: location.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Warehouse location ${location.code} dibuat` + }); + return NextResponse.json({ data: serializeWarehouseLocation(location) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode lokasi sudah dipakai di gudang ini"] } }, + { status: 409 } + ); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2003") { + return NextResponse.json( + { message: "Validasi gagal", errors: { warehouse_id: ["Gudang tidak valid"] } }, + { status: 400 } + ); + } + throw error; + } +} diff --git a/src/app/api/v1/warehouses/[id]/route.ts b/src/app/api/v1/warehouses/[id]/route.ts new file mode 100644 index 0000000..b141eec --- /dev/null +++ b/src/app/api/v1/warehouses/[id]/route.ts @@ -0,0 +1,123 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeWarehouse } from "@/features/warehouses/lib/serialize-warehouse"; +import { warehouseInputSchema } from "@/features/warehouses/schemas/warehouse.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; +const parseId = (id: string) => { try { return BigInt(id); } catch { return null; } }; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const warehouse = await prisma.warehouse.findUnique({ where: { id: parsedId } }); + if (!warehouse) return NextResponse.json({ message: "Warehouse not found" }, { status: 404 }); + return NextResponse.json({ data: serializeWarehouse(warehouse) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + const parsed = warehouseInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, { status: 400 }); + } + try { + const existing = await prisma.warehouse.findUnique({ where: { id: parsedId } }); + if (!existing) return NextResponse.json({ message: "Warehouse not found" }, { status: 404 }); + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "WHS", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => + prisma.warehouse.count({ where: { code: { startsWith: "WHS" } } }), + exists: async (code) => + (await prisma.warehouse.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json({ message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, { status: 400 }); + } + const warehouse = await prisma.warehouse.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + address: parsed.data.address || null, + status: parsed.data.status + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WAREHOUSE_UPDATED", + entityType: "WAREHOUSE", + entityId: warehouse.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Warehouse ${warehouse.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + address: existing.address, + status: existing.status + }, + { + code: warehouse.code, + name: warehouse.name, + address: warehouse.address, + status: warehouse.status + } + ) + }); + return NextResponse.json({ data: serializeWarehouse(warehouse) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Warehouse not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json({ message: "Validasi gagal", errors: { code: ["Kode gudang sudah dipakai"] } }, { status: 409 }); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + try { + const existing = await prisma.warehouse.findUnique({ where: { id: parsedId } }); + await prisma.warehouse.delete({ where: { id: parsedId } }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WAREHOUSE_DELETED", + entityType: "WAREHOUSE", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Warehouse ${existing?.code ?? parsedId.toString()} dihapus` + }); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Warehouse not found" }, { status: 404 }); + } + throw error; + } +} diff --git a/src/app/api/v1/warehouses/route.ts b/src/app/api/v1/warehouses/route.ts new file mode 100644 index 0000000..79a4ef9 --- /dev/null +++ b/src/app/api/v1/warehouses/route.ts @@ -0,0 +1,63 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeWarehouse } from "@/features/warehouses/lib/serialize-warehouse"; +import { warehouseInputSchema } from "@/features/warehouses/schemas/warehouse.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; +import { requireApiAccess } from "@/lib/authorization"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const data = await prisma.warehouse.findMany({ orderBy: [{ createdAt: "desc" }] }); + return NextResponse.json({ data: data.map(serializeWarehouse) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + const parsed = warehouseInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, { status: 400 }); + } + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "WHS", + requestedCode: parsed.data.code, + countExisting: () => + prisma.warehouse.count({ where: { code: { startsWith: "WHS" } } }), + exists: async (code) => + (await prisma.warehouse.count({ where: { code } })) > 0 + }); + if (!resolvedCode.ok) { + return NextResponse.json({ message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, { status: 400 }); + } + const warehouse = await prisma.warehouse.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + address: parsed.data.address || null, + status: parsed.data.status + } + }); + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WAREHOUSE_CREATED", + entityType: "WAREHOUSE", + entityId: warehouse.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Warehouse ${warehouse.code} dibuat` + }); + return NextResponse.json({ data: serializeWarehouse(warehouse) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json({ message: "Validasi gagal", errors: { code: ["Kode gudang sudah dipakai"] } }, { status: 409 }); + } + throw error; + } +} diff --git a/src/app/api/v1/washing-places/[id]/route.ts b/src/app/api/v1/washing-places/[id]/route.ts new file mode 100644 index 0000000..cb7aa0d --- /dev/null +++ b/src/app/api/v1/washing-places/[id]/route.ts @@ -0,0 +1,196 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeWashingPlace } from "@/features/washing-places/lib/serialize-washing-place"; +import { washingPlaceInputSchema } from "@/features/washing-places/schemas/washing-place.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const parseId = (id: string) => { + try { + return BigInt(id); + } catch { + return null; + } +}; + +export async function GET(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const item = await prisma.washingPlace.findUnique({ + where: { id: parsedId }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + if (!item) return NextResponse.json({ message: "Washing place not found" }, { status: 404 }); + + return NextResponse.json({ data: serializeWashingPlace(item) }); +} + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + const parsed = washingPlaceInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const existing = await prisma.washingPlace.findUnique({ + where: { id: parsedId }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + if (!existing) return NextResponse.json({ message: "Washing place not found" }, { status: 404 }); + + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "WPL", + requestedCode: parsed.data.code, + existingCode: existing.code, + countExisting: () => prisma.washingPlace.count({ where: { code: { startsWith: "WPL" } } }), + exists: async (code) => (await prisma.washingPlace.count({ where: { code, id: { not: parsedId } } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const item = await prisma.washingPlace.update({ + where: { id: parsedId }, + data: { + code: resolvedCode.code, + name: parsed.data.name, + address: parsed.data.address || null, + phone: parsed.data.phone || null, + website: parsed.data.website || null, + email: parsed.data.email || null, + contactPeople: { + deleteMany: {}, + create: parsed.data.contact_people.map((contact) => ({ + name: contact.name, + mobilePhone: contact.mobile_phone || null, + email: contact.email || null + })) + }, + status: parsed.data.status + }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WASHING_PLACE_UPDATED", + entityType: "WASHING_PLACE", + entityId: item.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Washing place ${item.code} diubah`, + metadata: buildAuditChangeMetadata( + { + code: existing.code, + name: existing.name, + address: existing.address, + phone: existing.phone, + website: existing.website, + email: existing.email, + contact_people: existing.contactPeople.map((contact) => ({ + name: contact.name, + mobile_phone: contact.mobilePhone, + email: contact.email + })), + status: existing.status + }, + { + code: item.code, + name: item.name, + address: item.address, + phone: item.phone, + website: item.website, + email: item.email, + contact_people: item.contactPeople.map((contact) => ({ + name: contact.name, + mobile_phone: contact.mobilePhone, + email: contact.email + })), + status: item.status + } + ) + }); + + return NextResponse.json({ data: serializeWashingPlace(item) }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Washing place not found" }, { status: 404 }); + } + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode tempat cuci sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsedId = parseId((await context.params).id); + if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + + try { + const existing = await prisma.washingPlace.findUnique({ where: { id: parsedId } }); + await prisma.washingPlace.delete({ where: { id: parsedId } }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WASHING_PLACE_DELETED", + entityType: "WASHING_PLACE", + entityId: parsedId, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Washing place ${existing?.code ?? parsedId.toString()} dihapus` + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") { + return NextResponse.json({ message: "Washing place not found" }, { status: 404 }); + } + throw error; + } +} + diff --git a/src/app/api/v1/washing-places/route.ts b/src/app/api/v1/washing-places/route.ts new file mode 100644 index 0000000..546d047 --- /dev/null +++ b/src/app/api/v1/washing-places/route.ts @@ -0,0 +1,101 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { NextResponse } from "next/server"; + +import { serializeWashingPlace } from "@/features/washing-places/lib/serialize-washing-place"; +import { washingPlaceInputSchema } from "@/features/washing-places/schemas/washing-place.schema"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { resolveMasterCode } from "@/lib/master-code"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const items = await prisma.washingPlace.findMany({ + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + }, + orderBy: [{ createdAt: "desc" }] + }); + + return NextResponse.json({ data: items.map(serializeWashingPlace) }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const parsed = washingPlaceInputSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + try { + const resolvedCode = await resolveMasterCode({ + role: auth.user.role, + prefix: "WPL", + requestedCode: parsed.data.code, + countExisting: () => prisma.washingPlace.count({ where: { code: { startsWith: "WPL" } } }), + exists: async (code) => (await prisma.washingPlace.count({ where: { code } })) > 0 + }); + + if (!resolvedCode.ok) { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: [resolvedCode.message] } }, + { status: 400 } + ); + } + + const item = await prisma.washingPlace.create({ + data: { + code: resolvedCode.code, + name: parsed.data.name, + address: parsed.data.address || null, + phone: parsed.data.phone || null, + website: parsed.data.website || null, + email: parsed.data.email || null, + contactPeople: { + create: parsed.data.contact_people.map((contact) => ({ + name: contact.name, + mobilePhone: contact.mobile_phone || null, + email: contact.email || null + })) + }, + status: parsed.data.status + }, + include: { + contactPeople: { + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + } + } + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WASHING_PLACE_CREATED", + entityType: "WASHING_PLACE", + entityId: item.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Washing place ${item.code} dibuat` + }); + + return NextResponse.json({ data: serializeWashingPlace(item) }, { status: 201 }); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json( + { message: "Validasi gagal", errors: { code: ["Kode tempat cuci sudah dipakai"] } }, + { status: 409 } + ); + } + throw error; + } +} + diff --git a/src/app/api/v1/washing/[id]/complete/route.ts b/src/app/api/v1/washing/[id]/complete/route.ts new file mode 100644 index 0000000..e48585b --- /dev/null +++ b/src/app/api/v1/washing/[id]/complete/route.ts @@ -0,0 +1,321 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { completeWashingSchema } from "@/features/washing/schemas/washing.schema"; +import { buildAllocationShares, getEffectiveLotAllocations, roundAmount, roundQty } from "@/features/purchase-realization/lib/lot-allocation"; +import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary"; +import { serializeWashing } from "@/features/washing/lib/serialize-washing"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const washingInclude = { + lot: { + include: { + purchaseAllocations: true, + purchase: { + select: { + id: true, + agentId: true, + profitShareSchemeId: true, + profitShareScheme: { + select: { + shareAgent: true + } + }, + agent: { + select: { + name: true + } + } + } + }, + grade: { select: { name: true } }, + unit: { select: { code: true } }, + warehouse: { select: { name: true } }, + warehouseLocation: { select: { name: true } } + } + }, + washingPlace: { + select: { + id: true, + name: true + } + } +} as const; + +type WashingTx = Prisma.TransactionClient & { + purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry; + purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary; + lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation; +}; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function POST(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const washingId = parseBigInt((await context.params).id); + if (washingId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + const parsed = completeWashingSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { + message: "Validasi gagal", + errors: parsed.error.flatten().fieldErrors + }, + { status: 422 } + ); + } + + const gradeId = + parsed.data.grade_id && parsed.data.grade_id.trim() !== "" + ? parseBigInt(parsed.data.grade_id) + : null; + const warehouseId = parseBigInt(parsed.data.warehouse_id); + const warehouseLocationId = + parsed.data.warehouse_location_id && parsed.data.warehouse_location_id.trim() !== "" + ? parseBigInt(parsed.data.warehouse_location_id) + : null; + + if ( + warehouseId === null || + (parsed.data.grade_id && gradeId === null) || + (parsed.data.warehouse_location_id && warehouseLocationId === null) + ) { + return NextResponse.json({ message: "Referensi grade atau gudang tidak valid" }, { status: 400 }); + } + + const washing = await prisma.lotWashing.findUnique({ + where: { id: washingId }, + include: { + lot: { + include: { + purchase: { + select: { + id: true, + agentId: true, + profitShareSchemeId: true, + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }, + purchaseAllocations: true, + grade: true, + warehouse: true, + warehouseLocation: true + } + } + } + }); + if (!washing) { + return NextResponse.json({ message: "Data washing tidak ditemukan" }, { status: 404 }); + } + if (washing.status === "DONE") { + return NextResponse.json({ message: "Washing ini sudah selesai" }, { status: 409 }); + } + if (washing.lot.reservedQty.toNumber() > 0) { + return NextResponse.json( + { message: "Lot dengan qty reserved tidak bisa diselesaikan dari washing" }, + { status: 422 } + ); + } + + const [grade, warehouse, warehouseLocation] = await Promise.all([ + gradeId ? prisma.grade.findUnique({ where: { id: gradeId } }) : Promise.resolve(null), + prisma.warehouse.findUnique({ where: { id: warehouseId } }), + warehouseLocationId + ? prisma.warehouseLocation.findUnique({ where: { id: warehouseLocationId } }) + : Promise.resolve(null) + ]); + + if (!warehouse) { + return NextResponse.json({ message: "Gudang tujuan tidak ditemukan" }, { status: 404 }); + } + if (warehouseLocationId && !warehouseLocation) { + return NextResponse.json({ message: "Lokasi gudang tidak ditemukan" }, { status: 404 }); + } + if (warehouseLocation && warehouseLocation.warehouseId !== warehouse.id) { + return NextResponse.json( + { message: "Lokasi gudang tidak sesuai dengan gudang tujuan" }, + { status: 422 } + ); + } + if (gradeId && !grade) { + return NextResponse.json({ message: "Grade lot tidak ditemukan" }, { status: 404 }); + } + + const beforeQty = washing.lot.availableQty.toNumber(); + if (parsed.data.after_qty > beforeQty) { + return NextResponse.json( + { message: "Berat setelah dicuci tidak boleh lebih besar dari berat awal lot" }, + { status: 422 } + ); + } + + const shrinkageQty = Number((beforeQty - parsed.data.after_qty).toFixed(3)); + + const completed = await prisma.$transaction(async (rawTx) => { + const tx = rawTx as WashingTx; + await tx.inventoryLot.update({ + where: { id: washing.lotId }, + data: { + originalQty: new Prisma.Decimal(parsed.data.after_qty), + availableQty: new Prisma.Decimal(parsed.data.after_qty), + shrinkageQty: new Prisma.Decimal( + Number((washing.lot.shrinkageQty.toNumber() + shrinkageQty).toFixed(3)) + ), + gradeId: gradeId ?? washing.lot.gradeId, + warehouseId: warehouse.id, + warehouseLocationId: warehouseLocation?.id ?? null, + status: parsed.data.after_qty > 0 ? "ACTIVE" : "DEPLETED" + } + }); + + await tx.lotWashing.update({ + where: { id: washing.id }, + data: { + status: "DONE", + completedAt: new Date(), + completedById: BigInt(auth.user.id), + afterQty: new Prisma.Decimal(parsed.data.after_qty), + shrinkageQty: new Prisma.Decimal(shrinkageQty), + afterGradeName: grade?.name ?? washing.lot.grade?.name ?? null, + afterWarehouseName: warehouse.name, + afterLocationName: warehouseLocation?.name ?? null + } + }); + + const allocations = getEffectiveLotAllocations(washing.lot); + const affectedPurchaseIds = new Set(); + const sourceAvailableQty = washing.lot.availableQty.toNumber(); + const allocationShares = buildAllocationShares(allocations, sourceAvailableQty, beforeQty); + + for (const share of allocationShares) { + affectedPurchaseIds.add(share.allocation.purchaseId); + const washingCostAmount = roundAmount(washing.washingCost.toNumber() * share.ratio); + if (washingCostAmount > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: washing.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "WASHING_COST", + referenceType: "LOT_WASHING", + referenceId: washing.id, + occurredAt: new Date(), + qtyIn: new Prisma.Decimal(0), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(0), + amountCost: new Prisma.Decimal(washingCostAmount), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Biaya washing ${washing.washingNo}` + } + }); + } + + const shrinkageAllocationQty = roundQty(shrinkageQty * share.ratio); + if (shrinkageAllocationQty > 0) { + await tx.purchaseRealizationEntry.create({ + data: { + purchaseId: share.allocation.purchaseId, + lotId: washing.lotId, + allocationId: share.allocation.id ?? undefined, + eventType: "WASHING_SHRINKAGE", + referenceType: "LOT_WASHING", + referenceId: washing.id, + occurredAt: new Date(), + qtyIn: new Prisma.Decimal(0), + qtyOut: new Prisma.Decimal(0), + qtyShrinkage: new Prisma.Decimal(shrinkageAllocationQty), + amountCost: new Prisma.Decimal(0), + amountRevenue: new Prisma.Decimal(0), + amountExpense: new Prisma.Decimal(0), + amountProfit: new Prisma.Decimal(0), + agentSharePercentSnapshot: null, + agentAmount: new Prisma.Decimal(0), + notes: `Susut washing ${washing.washingNo}` + } + }); + } + + if (share.allocation.id) { + await tx.lotPurchaseAllocation.update({ + where: { id: share.allocation.id }, + data: { + qtyAllocated: new Prisma.Decimal(roundQty(parsed.data.after_qty * share.ratio)), + costTotalAllocated: new Prisma.Decimal( + roundAmount(parsed.data.after_qty * share.ratio * share.allocation.unitCostSnapshot.toNumber()) + ) + } + }); + } + } + + for (const purchaseId of affectedPurchaseIds) { + const sourcePurchase = await tx.purchase.findUnique({ + where: { id: purchaseId }, + select: { + profitShareScheme: { + select: { + shareAgent: true + } + } + } + }); + await recalculatePurchaseRealizationSummary( + tx, + purchaseId, + sourcePurchase?.profitShareScheme?.shareAgent.toNumber() ?? null + ); + } + + return tx.lotWashing.findUniqueOrThrow({ + where: { id: washing.id }, + include: washingInclude + }); + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WASHING_COMPLETED", + entityType: "LOT_WASHING", + entityId: completed.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Washing ${completed.washingNo} selesai`, + metadata: { + washing_no: completed.washingNo, + lot_code: completed.lot.lotCode, + before_qty: beforeQty, + after_qty: parsed.data.after_qty, + shrinkage_qty: shrinkageQty + } + }); + + return NextResponse.json({ data: serializeWashing(completed) }); +} diff --git a/src/app/api/v1/washing/[id]/route.ts b/src/app/api/v1/washing/[id]/route.ts new file mode 100644 index 0000000..e2fde07 --- /dev/null +++ b/src/app/api/v1/washing/[id]/route.ts @@ -0,0 +1,148 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { + isWashingPayloadValidationError, + parseWashingRequestPayload +} from "@/features/washing/lib/parse-washing-request"; +import { serializeWashing } from "@/features/washing/lib/serialize-washing"; +import { + storeWashingReceipt, + validateWashingReceiptFile +} from "@/features/washing/lib/store-washing-receipt"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const washingInclude = { + lot: { + include: { + purchase: { + select: { + agent: { + select: { + name: true + } + } + } + }, + grade: { select: { name: true } }, + unit: { select: { code: true } }, + warehouse: { select: { name: true } }, + warehouseLocation: { select: { name: true } } + } + }, + washingPlace: { + select: { + id: true, + name: true + } + } +} as const; + +type RouteContext = { + params: Promise<{ + id: string; + }>; +}; + +export async function PUT(request: Request, context: RouteContext) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const washingId = parseBigInt((await context.params).id); + if (washingId === null) { + return NextResponse.json({ message: "Invalid id" }, { status: 400 }); + } + + let parsedRequest; + try { + parsedRequest = await parseWashingRequestPayload(request); + } catch (error) { + if (isWashingPayloadValidationError(error)) { + return NextResponse.json( + { + message: error.message, + errors: error.errors + }, + { status: 422 } + ); + } + return NextResponse.json({ message: "Payload washing tidak valid" }, { status: 400 }); + } + + const washingPlaceId = parseBigInt(parsedRequest.payload.washing_place_id); + const lotId = parseBigInt(parsedRequest.payload.lot_id); + if (washingPlaceId === null || lotId === null) { + return NextResponse.json({ message: "Referensi washing tidak valid" }, { status: 400 }); + } + + const existing = await prisma.lotWashing.findUnique({ + where: { id: washingId } + }); + if (!existing) { + return NextResponse.json({ message: "Data washing tidak ditemukan" }, { status: 404 }); + } + if (existing.status === "DONE") { + return NextResponse.json( + { message: "Data washing yang sudah selesai tidak bisa diubah" }, + { status: 422 } + ); + } + if (existing.lotId !== lotId) { + return NextResponse.json({ message: "Lot washing tidak bisa diganti" }, { status: 422 }); + } + + const washingPlace = await prisma.washingPlace.findUnique({ where: { id: washingPlaceId } }); + if (!washingPlace) { + return NextResponse.json({ message: "Tempat cuci tidak ditemukan" }, { status: 404 }); + } + + let receiptFileUrl = existing.receiptFileUrl; + if (parsedRequest.receiptFile) { + const validationError = validateWashingReceiptFile(parsedRequest.receiptFile); + if (validationError) { + return NextResponse.json({ message: validationError }, { status: 422 }); + } + receiptFileUrl = await storeWashingReceipt(parsedRequest.receiptFile); + } + + const updated = await prisma.lotWashing.update({ + where: { id: washingId }, + data: { + washingPlaceId, + washingCost: new Prisma.Decimal(parsedRequest.payload.washing_cost), + durationHours: parsedRequest.payload.duration_hours, + receiptFileUrl, + expectedDoneAt: new Date( + existing.startedAt.getTime() + parsedRequest.payload.duration_hours * 60 * 60 * 1000 + ) + }, + include: washingInclude + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WASHING_UPDATED", + entityType: "LOT_WASHING", + entityId: updated.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 200, + summary: `Washing ${updated.washingNo} diubah`, + metadata: { + washing_no: updated.washingNo, + washing_place: updated.washingPlace.name + } + }); + + return NextResponse.json({ data: serializeWashing(updated) }); +} diff --git a/src/app/api/v1/washing/route.ts b/src/app/api/v1/washing/route.ts new file mode 100644 index 0000000..a49c5ba --- /dev/null +++ b/src/app/api/v1/washing/route.ts @@ -0,0 +1,187 @@ +import { Prisma } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { generateWashingNo } from "@/features/washing/lib/generate-washing-no"; +import { + isWashingPayloadValidationError, + parseWashingRequestPayload +} from "@/features/washing/lib/parse-washing-request"; +import { serializeWashing } from "@/features/washing/lib/serialize-washing"; +import { + storeWashingReceipt, + validateWashingReceiptFile +} from "@/features/washing/lib/store-washing-receipt"; +import { createAuditTrailSafe } from "@/lib/audit-trail"; +import { requireApiAccess } from "@/lib/authorization"; +import { prisma } from "@/lib/prisma"; + +function parseBigInt(value: string) { + try { + return BigInt(value); + } catch { + return null; + } +} + +const washingInclude = { + lot: { + include: { + purchase: { + select: { + agent: { + select: { + name: true + } + } + } + }, + grade: { select: { name: true } }, + unit: { select: { code: true } }, + warehouse: { select: { name: true } }, + warehouseLocation: { select: { name: true } } + } + }, + washingPlace: { + select: { + id: true, + name: true + } + } +} as const; + +export async function GET(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + const items = await prisma.lotWashing.findMany({ + include: washingInclude, + orderBy: [{ startedAt: "desc" }, { createdAt: "desc" }] + }); + + return NextResponse.json({ + data: items.map(serializeWashing) + }); +} + +export async function POST(request: Request) { + const auth = requireApiAccess(request); + if (!auth.ok) return auth.response; + + let parsedRequest; + try { + parsedRequest = await parseWashingRequestPayload(request); + } catch (error) { + if (isWashingPayloadValidationError(error)) { + return NextResponse.json( + { + message: error.message, + errors: error.errors + }, + { status: 422 } + ); + } + return NextResponse.json({ message: "Payload washing tidak valid" }, { status: 400 }); + } + + const lotId = parseBigInt(parsedRequest.payload.lot_id); + const washingPlaceId = parseBigInt(parsedRequest.payload.washing_place_id); + if (lotId === null || washingPlaceId === null) { + return NextResponse.json({ message: "Referensi lot atau tempat cuci tidak valid" }, { status: 400 }); + } + + const [lot, washingPlace, existingInProgress] = await Promise.all([ + prisma.inventoryLot.findUnique({ + where: { id: lotId }, + include: { + grade: true, + warehouse: true, + warehouseLocation: true + } + }), + prisma.washingPlace.findUnique({ where: { id: washingPlaceId } }), + prisma.lotWashing.findFirst({ + where: { + lotId, + status: "IN_PROGRESS" + } + }) + ]); + + if (!lot) { + return NextResponse.json({ message: "Lot tidak ditemukan" }, { status: 404 }); + } + if (!washingPlace) { + return NextResponse.json({ message: "Tempat cuci tidak ditemukan" }, { status: 404 }); + } + if (existingInProgress) { + return NextResponse.json( + { message: "Lot ini masih memiliki proses washing yang belum selesai" }, + { status: 409 } + ); + } + if (lot.status !== "ACTIVE" || lot.availableQty.toNumber() <= 0) { + return NextResponse.json( + { message: "Lot harus aktif dan memiliki stok tersedia untuk dikirim ke washing" }, + { status: 422 } + ); + } + if (lot.reservedQty.toNumber() > 0) { + return NextResponse.json( + { message: "Lot dengan qty reserved tidak bisa dikirim ke washing" }, + { status: 422 } + ); + } + + let receiptFileUrl: string | null = null; + if (parsedRequest.receiptFile) { + const validationError = validateWashingReceiptFile(parsedRequest.receiptFile); + if (validationError) { + return NextResponse.json({ message: validationError }, { status: 422 }); + } + receiptFileUrl = await storeWashingReceipt(parsedRequest.receiptFile); + } + + const startedAt = new Date(); + const expectedDoneAt = new Date( + startedAt.getTime() + parsedRequest.payload.duration_hours * 60 * 60 * 1000 + ); + const washingNo = await generateWashingNo(startedAt); + + const created = await prisma.lotWashing.create({ + data: { + washingNo, + lotId, + washingPlaceId, + washingCost: new Prisma.Decimal(parsedRequest.payload.washing_cost), + durationHours: parsedRequest.payload.duration_hours, + receiptFileUrl, + startedAt, + expectedDoneAt, + beforeQty: lot.availableQty, + beforeGradeName: lot.grade?.name ?? null, + beforeWarehouseName: lot.warehouse.name, + beforeLocationName: lot.warehouseLocation?.name ?? null, + createdById: BigInt(auth.user.id) + }, + include: washingInclude + }); + + await createAuditTrailSafe({ + userId: auth.user.id, + action: "WASHING_CREATED", + entityType: "LOT_WASHING", + entityId: created.id, + method: request.method, + pathname: new URL(request.url).pathname, + statusCode: 201, + summary: `Washing ${created.washingNo} dibuat untuk lot ${created.lot.lotCode}`, + metadata: { + washing_no: created.washingNo, + lot_code: created.lot.lotCode, + washing_place: created.washingPlace.name, + before_qty: created.beforeQty.toNumber() + } + }); + + return NextResponse.json({ data: serializeWashing(created) }, { status: 201 }); +} diff --git a/src/app/audit-trail/page.tsx b/src/app/audit-trail/page.tsx new file mode 100644 index 0000000..5802722 --- /dev/null +++ b/src/app/audit-trail/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { AuditTrailClient } from "@/features/audit-trail/components/audit-trail-client"; + +export default async function AuditTrailPage() { + return ( + + + + ); +} diff --git a/src/app/banks/page.tsx b/src/app/banks/page.tsx new file mode 100644 index 0000000..4cc89ec --- /dev/null +++ b/src/app/banks/page.tsx @@ -0,0 +1,19 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SimpleMasterCrud } from "@/components/master-data/simple-master-crud"; + +export default async function BanksPage() { + return ( + + + + ); +} diff --git a/src/app/barcode/lookup/page.tsx b/src/app/barcode/lookup/page.tsx new file mode 100644 index 0000000..a05246e --- /dev/null +++ b/src/app/barcode/lookup/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { BarcodeLookupClient } from "@/features/barcode/components/barcode-lookup-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function BarcodeLookupPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/buyers/page.tsx b/src/app/buyers/page.tsx new file mode 100644 index 0000000..22b36d6 --- /dev/null +++ b/src/app/buyers/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { BuyersClient } from "@/features/buyers/components/buyers-client"; + +export default async function BuyersPage() { + return ( + + + + ); +} diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx new file mode 100644 index 0000000..4e21886 --- /dev/null +++ b/src/app/change-password/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { ChangePasswordClient } from "@/features/auth/components/change-password-client"; + +export default async function ChangePasswordPage() { + return ( + + + + ); +} diff --git a/src/app/consignments/page.tsx b/src/app/consignments/page.tsx new file mode 100644 index 0000000..b909892 --- /dev/null +++ b/src/app/consignments/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { ConsignmentsClient } from "@/features/consignments/components/consignments-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function ConsignmentsPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/couriers/page.tsx b/src/app/couriers/page.tsx new file mode 100644 index 0000000..e762577 --- /dev/null +++ b/src/app/couriers/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { CouriersClient } from "@/components/master-data/couriers-client"; + +export default async function CouriersPage() { + return ( + + + + ); +} diff --git a/src/app/currencies/page.tsx b/src/app/currencies/page.tsx new file mode 100644 index 0000000..aca74f1 --- /dev/null +++ b/src/app/currencies/page.tsx @@ -0,0 +1,19 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SimpleMasterCrud } from "@/components/master-data/simple-master-crud"; + +export default async function CurrenciesPage() { + return ( + + + + ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..6b6f082 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,110 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { MovementFeed } from "@/components/dashboard/movement-feed"; +import { MetricCard } from "@/components/dashboard/metric-card"; +import { QuickActions } from "@/components/dashboard/quick-actions"; +import { LotTable } from "@/components/dashboard/lot-table"; +import { getDashboardData } from "@/lib/dashboard"; +import { getCurrentLocale } from "@/lib/i18n-server"; +import { getDictionary } from "@/lib/i18n"; + +export default async function DashboardPage() { + const locale = await getCurrentLocale(); + const dict = getDictionary(locale); + const dashboard = await getDashboardData(locale); + const maxVolume = Math.max( + ...dashboard.monthlyVolume.flatMap((item) => [item.purchaseQty, item.salesQty]), + 1 + ); + + return ( + +
+ {dashboard.metrics.map((metric) => ( + + ))} +
+
+
+
+
+

{dict.dashboard.purchaseVsSales}

+

{dict.dashboard.purchaseVsSalesCopy}

+
+ +
+
+ {dashboard.monthlyVolume.map((item) => ( +
+
+
0 ? 8 : 0)}%` }} + /> +
+
+
0 ? 8 : 0)}%` }} + /> +
+ {item.label} +
+ ))} +
+
+ {dashboard.monthlyVolume.map((item) => ( + + {item.label} + + ))} +
+
+ + + {dict.dashboard.purchases} + + + + {dict.dashboard.sales} + +
+
+ +
+
+ +
+
+
+
+

{dict.dashboard.topPartners}

+

{dict.dashboard.topPartnersCopy}

+
+
+
+ {dashboard.topPartners.map((partner) => ( +
+
+

{partner.name}

+

{partner.detail}

+
+ {partner.inventoryValue} +
+ ))} +
+
+ +
+
+
+ ); +} diff --git a/src/app/employees/page.tsx b/src/app/employees/page.tsx new file mode 100644 index 0000000..cb1e215 --- /dev/null +++ b/src/app/employees/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { EmployeesClient } from "@/components/master-data/employees-client"; + +export default async function EmployeesPage() { + return ( + + + + ); +} diff --git a/src/app/fund-requests/page.tsx b/src/app/fund-requests/page.tsx new file mode 100644 index 0000000..69d7871 --- /dev/null +++ b/src/app/fund-requests/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { FundRequestsClient } from "@/features/fund-requests/components/fund-requests-client"; + +export default function FundRequestsPage() { + return ( + + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..576c753 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,168 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --color-canvas: 248 250 250; + --color-sand: 242 244 244; + --color-ink: 25 28 29; + --color-moss: 13 94 103; + --color-ember: 186 26 26; + --color-line: 191 200 202; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: rgb(var(--color-canvas)); + color: rgb(var(--color-ink)); + font-family: var(--font-body), sans-serif; +} + +input, +select, +textarea, +button { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +::selection { + background: rgba(13, 94, 103, 0.18); +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(111, 121, 122, 0.35); + border-radius: 9999px; +} + +@layer components { + .ops-card { + @apply rounded-lg border border-line/70 bg-white shadow-panel; + } + + .ops-card-muted { + @apply rounded-lg border border-line/70 bg-slate-50/70 shadow-panel; + } + + .ops-card-dark { + @apply rounded-lg border border-moss/80 bg-moss text-white shadow-panel; + } + + .ops-section-head { + @apply flex items-start justify-between gap-4 border-b border-line/70 px-6 py-4; + } + + .ops-overline { + @apply text-[11px] font-bold uppercase tracking-[0.08em] text-slate-500; + } + + .ops-title { + @apply text-[20px] font-semibold leading-7 text-ink; + } + + .ops-copy { + @apply text-[13px] leading-[18px] text-slate-500; + } + + .ops-label { + @apply mb-1.5 block text-[11px] font-bold uppercase tracking-[0.05em] text-slate-500; + } + + .ops-input, + .ops-select, + .ops-textarea { + @apply w-full rounded border border-line/80 bg-white px-3 py-2 text-[13px] text-ink outline-none transition focus:border-moss focus:ring-2 focus:ring-moss/15; + } + + .ops-input:disabled, + .ops-select:disabled, + .ops-textarea:disabled { + @apply cursor-not-allowed bg-slate-100 text-slate-500; + } + + .ops-btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded border border-moss bg-moss px-4 py-2 text-[13px] font-semibold text-white transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60; + } + + .ops-btn-secondary { + @apply inline-flex items-center justify-center gap-2 rounded border border-line/80 bg-white px-4 py-2 text-[13px] font-semibold text-moss transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60; + } + + .ops-btn-danger { + @apply inline-flex items-center justify-center gap-2 rounded border border-red-200 bg-white px-4 py-2 text-[13px] font-semibold text-red-700 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60; + } + + .ops-chip { + @apply inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold; + } + + .ops-chip-active { + @apply ops-chip bg-emerald-100 text-emerald-700; + } + + .ops-chip-warning { + @apply ops-chip bg-amber-100 text-amber-700; + } + + .ops-chip-danger { + @apply ops-chip bg-red-100 text-red-700; + } + + .ops-chip-muted { + @apply ops-chip bg-slate-100 text-slate-600; + } + + .ops-metric { + @apply ops-card p-5; + } + + .ops-table-shell { + @apply ops-card overflow-hidden; + } + + .ops-table { + @apply min-w-full text-[13px]; + } + + .ops-table thead { + @apply bg-slate-100/95 text-left text-slate-500; + } + + .ops-table th { + @apply px-6 py-3 text-[11px] font-bold uppercase tracking-[0.05em]; + } + + .ops-table td { + @apply px-6 py-4 align-top text-[13px] text-ink; + } + + .ops-table tbody tr { + @apply border-t border-line/70; + } + + .ops-note { + @apply rounded-lg border border-blue-200 bg-blue-50 px-5 py-4 text-[13px] leading-6 text-slate-600; + } +} diff --git a/src/app/grades/page.tsx b/src/app/grades/page.tsx new file mode 100644 index 0000000..0e9c634 --- /dev/null +++ b/src/app/grades/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { GradesClient } from "@/components/master-data/grades-client"; + +export default async function GradesPage() { + return ( + + + + ); +} diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx new file mode 100644 index 0000000..7d5a105 --- /dev/null +++ b/src/app/help/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { HelpClient } from "@/features/help/components/help-client"; + +export default async function HelpPage() { + return ( + + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..73124ee --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; + +import { LocaleProvider } from "@/components/providers/locale-provider"; +import { getCurrentLocale } from "@/lib/i18n-server"; +import "./globals.css"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-body" +}); + +export const metadata: Metadata = { + title: "AbelBirdnest Stock", + description: "AbelBirdnest Stock untuk lot persediaan, biaya perolehan, dan ketertelusuran.", + icons: { + icon: "/favicon.ico" + } +}; + +export default async function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + const locale = await getCurrentLocale(); + + return ( + + + +
+ {children} +
+
+ + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..d69bed9 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +import { LoginClient } from "@/features/auth/components/login-client"; +import { getSessionUser } from "@/lib/auth-server"; + +export default async function LoginPage() { + const user = await getSessionUser(); + + if (user) { + redirect("/dashboard"); + } + + return ; +} diff --git a/src/app/lots/[id]/page.tsx b/src/app/lots/[id]/page.tsx new file mode 100644 index 0000000..57c9214 --- /dev/null +++ b/src/app/lots/[id]/page.tsx @@ -0,0 +1,22 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { LotDetailClient } from "@/features/lots/components/lot-detail-client"; +import { getAppSettings } from "@/lib/app-settings"; + +type LotDetailPageProps = { + params: Promise<{ id: string }>; +}; + +export default async function LotDetailPage({ params }: LotDetailPageProps) { + const { id } = await params; + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/lots/page.tsx b/src/app/lots/page.tsx new file mode 100644 index 0000000..b47ace4 --- /dev/null +++ b/src/app/lots/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { LotsClient } from "@/features/lots/components/lots-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function LotsPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..280b176 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +import { getSessionUser } from "@/lib/auth-server"; + +export default async function HomePage() { + const user = await getSessionUser(); + redirect(user ? "/dashboard" : "/login"); +} diff --git a/src/app/profit-share-schemes/page.tsx b/src/app/profit-share-schemes/page.tsx new file mode 100644 index 0000000..abfedb3 --- /dev/null +++ b/src/app/profit-share-schemes/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { ProfitShareSchemesClient } from "@/components/master-data/profit-share-schemes-client"; + +export default async function ProfitShareSchemesPage() { + return ( + + + + ); +} diff --git a/src/app/purchase-analysis/page.tsx b/src/app/purchase-analysis/page.tsx new file mode 100644 index 0000000..448e2ae --- /dev/null +++ b/src/app/purchase-analysis/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { PurchaseAnalysisClient } from "@/features/purchase-analysis/components/purchase-analysis-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function PurchaseAnalysisPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/purchase-realization/page.tsx b/src/app/purchase-realization/page.tsx new file mode 100644 index 0000000..523f18f --- /dev/null +++ b/src/app/purchase-realization/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { PurchaseRealizationClient } from "@/features/purchase-realization/components/purchase-realization-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function PurchaseRealizationPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/purchases/office-buyout/page.tsx b/src/app/purchases/office-buyout/page.tsx new file mode 100644 index 0000000..addcde3 --- /dev/null +++ b/src/app/purchases/office-buyout/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { OfficeBuyoutClient } from "@/features/purchases/components/office-buyout-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function OfficeBuyoutPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/purchases/page.tsx b/src/app/purchases/page.tsx new file mode 100644 index 0000000..cef4c8b --- /dev/null +++ b/src/app/purchases/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { PurchasesClient } from "@/features/purchases/components/purchases-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function PurchasesPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/receipts/page.tsx b/src/app/receipts/page.tsx new file mode 100644 index 0000000..f3221cc --- /dev/null +++ b/src/app/receipts/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { ReceiptsClient } from "@/features/receipts/components/receipts-client"; + +export default async function ReceiptsPage() { + return ( + + + + ); +} diff --git a/src/app/reports/stock-summary/page.tsx b/src/app/reports/stock-summary/page.tsx new file mode 100644 index 0000000..b3e4a0e --- /dev/null +++ b/src/app/reports/stock-summary/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { StockSummaryReportClient } from "@/features/reports/components/stock-summary-report-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function StockSummaryReportPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx new file mode 100644 index 0000000..b116576 --- /dev/null +++ b/src/app/reset-password/page.tsx @@ -0,0 +1,10 @@ +import { ResetPasswordClient } from "@/features/auth/components/reset-password-client"; + +export default async function ResetPasswordPage({ + searchParams +}: { + searchParams: Promise<{ token?: string }>; +}) { + const params = await searchParams; + return ; +} diff --git a/src/app/sales-allocation/page.tsx b/src/app/sales-allocation/page.tsx new file mode 100644 index 0000000..ff7f06d --- /dev/null +++ b/src/app/sales-allocation/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SalesAllocationClient } from "@/features/sales-allocation/components/sales-allocation-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function SalesPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/sales-jit/page.tsx b/src/app/sales-jit/page.tsx new file mode 100644 index 0000000..7b39128 --- /dev/null +++ b/src/app/sales-jit/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SalesJitClient } from "@/features/sales-jit/components/sales-jit-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function SalesJitPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/sales-regular/page.tsx b/src/app/sales-regular/page.tsx new file mode 100644 index 0000000..28f7625 --- /dev/null +++ b/src/app/sales-regular/page.tsx @@ -0,0 +1,17 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SalesRegularClient } from "@/features/sales-regular/components/sales-regular-client"; +import { getAppSettings } from "@/lib/app-settings"; + +export default async function SalesRegularPage() { + const settings = await getAppSettings(); + + return ( + + + + ); +} diff --git a/src/app/sales/page.tsx b/src/app/sales/page.tsx new file mode 100644 index 0000000..129a3c5 --- /dev/null +++ b/src/app/sales/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SalesClient } from "@/components/master-data/sales-client"; + +export default async function SellersPage() { + return ( + + + + ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..747352d --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SettingsClient } from "@/features/settings/components/settings-client"; + +export default async function SettingsPage() { + return ( + + + + ); +} diff --git a/src/app/sorting/page.tsx b/src/app/sorting/page.tsx new file mode 100644 index 0000000..5dee64b --- /dev/null +++ b/src/app/sorting/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { LotMixingClient } from "@/features/lot-transformations/components/lot-mixing-client"; + +export default async function SortingPage() { + return ( + + + + ); +} diff --git a/src/app/stock-adjustments/page.tsx b/src/app/stock-adjustments/page.tsx new file mode 100644 index 0000000..b09d3a9 --- /dev/null +++ b/src/app/stock-adjustments/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { StockAdjustmentsClient } from "@/features/stock-adjustments/components/stock-adjustments-client"; + +export default async function StockAdjustmentsPage() { + return ( + + + + ); +} diff --git a/src/app/units/page.tsx b/src/app/units/page.tsx new file mode 100644 index 0000000..3f2f6bc --- /dev/null +++ b/src/app/units/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { UnitsClient } from "@/components/master-data/units-client"; + +export default async function UnitsPage() { + return ( + + + + ); +} diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx new file mode 100644 index 0000000..ab4b473 --- /dev/null +++ b/src/app/users/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { UsersClient } from "@/features/users/components/users-client"; + +export default async function UsersPage() { + return ( + + + + ); +} diff --git a/src/app/verify-email/page.tsx b/src/app/verify-email/page.tsx new file mode 100644 index 0000000..f108d5e --- /dev/null +++ b/src/app/verify-email/page.tsx @@ -0,0 +1,10 @@ +import { VerifyEmailClient } from "@/features/auth/components/verify-email-client"; + +export default async function VerifyEmailPage({ + searchParams +}: { + searchParams: Promise<{ token?: string }>; +}) { + const params = await searchParams; + return ; +} diff --git a/src/app/warehouse-locations/page.tsx b/src/app/warehouse-locations/page.tsx new file mode 100644 index 0000000..b260bf1 --- /dev/null +++ b/src/app/warehouse-locations/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { WarehouseLocationsClient } from "@/features/warehouse-locations/components/warehouse-locations-client"; + +export default async function WarehouseLocationsPage() { + return ( + + + + ); +} diff --git a/src/app/warehouses/page.tsx b/src/app/warehouses/page.tsx new file mode 100644 index 0000000..0043996 --- /dev/null +++ b/src/app/warehouses/page.tsx @@ -0,0 +1,19 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { SimpleMasterCrud } from "@/components/master-data/simple-master-crud"; + +export default async function WarehousesPage() { + return ( + + + + ); +} diff --git a/src/app/washing-places/page.tsx b/src/app/washing-places/page.tsx new file mode 100644 index 0000000..6020027 --- /dev/null +++ b/src/app/washing-places/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { WashingPlacesClient } from "@/components/master-data/washing-places-client"; + +export default async function WashingPlacesPage() { + return ( + + + + ); +} diff --git a/src/app/washing/page.tsx b/src/app/washing/page.tsx new file mode 100644 index 0000000..43a5332 --- /dev/null +++ b/src/app/washing/page.tsx @@ -0,0 +1,14 @@ +import { AppShell } from "@/components/layout/app-shell"; +import { WashingClient } from "@/features/washing/components/washing-client"; + +export default async function WashingPage() { + return ( + + + + ); +} diff --git a/src/components/auth/logout-button.tsx b/src/components/auth/logout-button.tsx new file mode 100644 index 0000000..78283cf --- /dev/null +++ b/src/components/auth/logout-button.tsx @@ -0,0 +1,134 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { useLocale } from "@/components/providers/locale-provider"; + +type LogoutButtonProps = { + className?: string; + label?: string; + icon?: ReactNode; +}; + +export function LogoutButton({ + className, + label, + icon +}: LogoutButtonProps) { + const router = useRouter(); + const { dict } = useLocale(); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!open) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + } + } + + window.addEventListener("keydown", handleKeyDown); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + async function handleLogout() { + setLoading(true); + try { + await fetch("/api/v1/auth/logout", { + method: "POST" + }); + router.push("/login"); + router.refresh(); + } finally { + setLoading(false); + } + } + + return ( + <> + + + {mounted && open + ? createPortal( +
{ + if (event.target === event.currentTarget && !loading) { + setOpen(false); + } + }} + > +
+
+
+
+
+ +
+
+

+ {dict.common.logoutModalTitle} +

+
+

+ {dict.common.logoutModalDescription} +

+
+
+
+
+ + +
+
+
+
, + document.body + ) + : null} + + ); +} diff --git a/src/components/branding/app-logo.tsx b/src/components/branding/app-logo.tsx new file mode 100644 index 0000000..3932d7d --- /dev/null +++ b/src/components/branding/app-logo.tsx @@ -0,0 +1,24 @@ +import Image from "next/image"; + +type AppLogoProps = { + size?: number; + className?: string; + priority?: boolean; +}; + +export function AppLogo({ + size = 48, + className, + priority = false +}: AppLogoProps) { + return ( + AbelBirdnest Stock + ); +} diff --git a/src/components/dashboard/lot-table.tsx b/src/components/dashboard/lot-table.tsx new file mode 100644 index 0000000..97706c1 --- /dev/null +++ b/src/components/dashboard/lot-table.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; + +import { useLocale } from "@/components/providers/locale-provider"; +import type { LotSnapshot } from "@/types/dashboard"; + +type LotTableProps = { + lots: LotSnapshot[]; +}; + +const statusTone = { + LOW_STOCK: "bg-amber-100 text-amber-800", + HOLD: "bg-ember/15 text-ember", + AGING: "bg-ink/10 text-ink" +} as const; + +function formatAttentionStatus( + status: LotSnapshot["attentionStatus"], + dict: ReturnType["dict"] +) { + if (status === "LOW_STOCK") return dict.dashboard.lowStock; + if (status === "AGING") return dict.dashboard.aging; + return dict.dashboard.onHold; +} + +export function LotTable({ lots }: LotTableProps) { + const { dict } = useLocale(); + + return ( +
+
+
+

{dict.dashboard.criticalLots}

+

{dict.dashboard.criticalLotsCopy}

+
+
+
+ + + + + + + + + + + + + {lots.map((lot) => ( + + + + + + + + + ))} + +
{dict.dashboard.lotCode}Agen{dict.dashboard.item}{dict.dashboard.availableQty}{dict.dashboard.reason}{dict.dashboard.status}
{lot.lotCode}{lot.supplier}{lot.item}{lot.availableQty}{lot.reason} +
+ + {formatAttentionStatus(lot.attentionStatus, dict)} + + + {dict.common.detail} + +
+
+
+
+ ); +} diff --git a/src/components/dashboard/metric-card.tsx b/src/components/dashboard/metric-card.tsx new file mode 100644 index 0000000..b93abad --- /dev/null +++ b/src/components/dashboard/metric-card.tsx @@ -0,0 +1,17 @@ +"use client"; + +import type { Metric } from "@/types/dashboard"; + +type MetricCardProps = { + metric: Metric; +}; + +export function MetricCard({ metric }: MetricCardProps) { + return ( +
+

{metric.label}

+

{metric.value}

+

{metric.delta}

+
+ ); +} diff --git a/src/components/dashboard/movement-feed.tsx b/src/components/dashboard/movement-feed.tsx new file mode 100644 index 0000000..ba49830 --- /dev/null +++ b/src/components/dashboard/movement-feed.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useLocale } from "@/components/providers/locale-provider"; +import type { DashboardActivity, DashboardAgeBucket } from "@/types/dashboard"; + +type MovementFeedProps = { + receiptAcceptance: string; + receiptAcceptanceCopy: string; + lotAgeDistribution: DashboardAgeBucket[]; + recentActivity: DashboardActivity[]; +}; + +const toneClassMap = { + moss: "bg-moss", + teal: "bg-teal-700/80", + danger: "bg-red-500" +} as const; + +export function MovementFeed({ + receiptAcceptance, + receiptAcceptanceCopy, + lotAgeDistribution, + recentActivity +}: MovementFeedProps) { + const { locale, dict } = useLocale(); + + return ( +
+
+

+ {dict.dashboard.receiptAcceptance} +

+

{receiptAcceptance}

+

+ {receiptAcceptanceCopy} +

+
+ +
+

{dict.dashboard.lotAgeDistribution}

+
+ {lotAgeDistribution.map((item) => ( +
+
+ {item.label} + {item.percentage}% +
+
+
+
+
+ ))} +
+
+ +
+

{dict.dashboard.recentActivity}

+
+ {recentActivity.map((item) => ( +
+
{item.summary}
+
+ {new Date(item.occurredAt).toLocaleString(locale === "id" ? "id-ID" : "en-US")} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/dashboard/quick-actions.tsx b/src/components/dashboard/quick-actions.tsx new file mode 100644 index 0000000..351f853 --- /dev/null +++ b/src/components/dashboard/quick-actions.tsx @@ -0,0 +1,61 @@ +"use client"; + +import Link from "next/link"; + +import { useLocale } from "@/components/providers/locale-provider"; + +const actions: Array<{ + href: string; + idLabel: "createPurchase" | "prepareSales" | "viewLotTrace"; + idDetail: + | "createPurchaseDetail" + | "prepareSalesDetail" + | "viewLotTraceDetail"; + enLabel: string; + enDetail: string; +}> = [ + { href: "/purchases", idLabel: "createPurchase", idDetail: "createPurchaseDetail", enLabel: "Create purchase", enDetail: "Enter a multi-line purchase." }, + { href: "/sales-allocation", idLabel: "prepareSales", idDetail: "prepareSalesDetail", enLabel: "Prepare sales", enDetail: "Allocate lots and review costing." }, + { href: "/lots", idLabel: "viewLotTrace", idDetail: "viewLotTraceDetail", enLabel: "View lot trace", enDetail: "Audit lot movement and status." } +]; + +export function QuickActions() { + const { locale, dict } = useLocale(); + + return ( +
+
+
+

{dict.dashboard.quickActions}

+

{dict.dashboard.quickActionsCopy}

+
+
+
+ {actions.map((action) => ( + +

{locale === "id" ? labelMap[action.idLabel] : action.enLabel}

+

+ {locale === "id" ? detailMap[action.idDetail] : action.enDetail} +

+ + ))} +
+
+ ); +} + +const labelMap = { + createPurchase: "Buat purchase", + prepareSales: "Siapkan sales", + viewLotTrace: "Lihat trace lot" +} as const; + +const detailMap = { + createPurchaseDetail: "Entry pembelian multi-line.", + prepareSalesDetail: "Allocate lot dan cek costing.", + viewLotTraceDetail: "Audit movement dan status lot." +} as const; diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..7070619 --- /dev/null +++ b/src/components/layout/app-shell.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from "react"; + +import { requireAuthorizedPageUser } from "@/lib/authorization"; +import { getPageCopy } from "@/lib/i18n-server"; +import { MobileNav } from "@/components/layout/mobile-nav"; +import { Sidebar } from "@/components/layout/sidebar"; +import { Topbar } from "@/components/layout/topbar"; + +type AppShellProps = { + children: ReactNode; + title: string; + description: string; + pathname: string; +}; + +export async function AppShell({ + children, + title, + description, + pathname +}: AppShellProps) { + const user = await requireAuthorizedPageUser(pathname); + const copy = await getPageCopy(pathname, title, description); + + return ( +
+
+ +
+
+ + +
{children}
+
+
+
+
+ ); +} diff --git a/src/components/layout/language-switcher.tsx b/src/components/layout/language-switcher.tsx new file mode 100644 index 0000000..2c4974d --- /dev/null +++ b/src/components/layout/language-switcher.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useLocale } from "@/components/providers/locale-provider"; + +export function LanguageSwitcher() { + const { locale, setLocale } = useLocale(); + const flag = locale === "id" ? "🇮🇩" : "🇬🇧"; + + return ( + + ); +} diff --git a/src/components/layout/mobile-nav.tsx b/src/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..d9178f1 --- /dev/null +++ b/src/components/layout/mobile-nav.tsx @@ -0,0 +1,149 @@ +"use client"; + +import Link from "next/link"; +import { ChevronDown, CircleHelp, KeyRound, LogOut } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { LogoutButton } from "@/components/auth/logout-button"; +import { useLocale } from "@/components/providers/locale-provider"; +import { getNavigationForRole, isNavGroup } from "@/config/navigation"; +import type { AppRole } from "@/config/access-control"; +import { cn } from "@/lib/utils"; + +type MobileNavProps = { + pathname: string; + userRole: string; +}; + +function matchesPath(currentPath: string, href: string) { + return currentPath === href || currentPath.startsWith(`${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>((acc, item) => { + const isGroupActive = item.children.some( + (child) => matchesPath(pathname, child.href) + ); + acc[item.key] = isGroupActive; + return acc; + }, {}); + }; + + const [openGroups, setOpenGroups] = useState>(buildOpenGroups); + + useEffect(() => { + setOpenGroups((current) => { + const next = { ...current }; + navigation.filter(isNavGroup).forEach((item) => { + const isGroupActive = item.children.some( + (child) => matchesPath(pathname, child.href) + ); + if (isGroupActive) { + next[item.key] = true; + } + }); + return next; + }); + }, [pathname, navigation]); + + return ( + + ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..8ee58e6 --- /dev/null +++ b/src/components/layout/sidebar.tsx @@ -0,0 +1,191 @@ +"use client"; + +import Link from "next/link"; +import { ChevronDown, ChevronRight, CircleHelp, KeyRound, LogOut } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { AppLogo } from "@/components/branding/app-logo"; +import { LogoutButton } from "@/components/auth/logout-button"; +import { useLocale } from "@/components/providers/locale-provider"; +import { getNavigationForRole, isNavGroup } from "@/config/navigation"; +import type { SessionUser } from "@/lib/auth"; +import { cn } from "@/lib/utils"; + +type SidebarProps = { + pathname: string; + user: SessionUser; +}; + +function matchesPath(currentPath: string, href: string) { + return currentPath === href || currentPath.startsWith(`${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>((acc, item) => { + const isGroupActive = item.children.some( + (child) => matchesPath(pathname, child.href) + ); + acc[item.key] = isGroupActive; + return acc; + }, {}); + }; + + const [openGroups, setOpenGroups] = useState>(buildOpenGroups); + + useEffect(() => { + setOpenGroups((current) => { + const next = { ...current }; + navigation.filter(isNavGroup).forEach((item) => { + const isGroupActive = item.children.some( + (child) => matchesPath(pathname, child.href) + ); + if (isGroupActive) { + next[item.key] = true; + } + }); + return next; + }); + }, [pathname, navigation]); + + return ( + + ); +} diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx new file mode 100644 index 0000000..fa6a91c --- /dev/null +++ b/src/components/layout/topbar.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { CircleHelp, LogOut, Search } from "lucide-react"; + +import { LogoutButton } from "@/components/auth/logout-button"; +import { LanguageSwitcher } from "@/components/layout/language-switcher"; +import { useLocale } from "@/components/providers/locale-provider"; +import type { SessionUser } from "@/lib/auth"; + +type TopbarProps = { + title: string; + description: string; + user: SessionUser; +}; + +export function Topbar({ title, description, user }: TopbarProps) { + const { dict } = useLocale(); + + return ( + <> +
+
+
+ + +
+
+
+ + + + + } + /> +
+
+

{user.name}

+

+ {user.role} +

+
+
+ {user.name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join("")} +
+
+
+
+
+

{title}

+

+ {description} +

+
+ + ); +} diff --git a/src/components/master-data/adjustment-reasons-client.tsx b/src/components/master-data/adjustment-reasons-client.tsx new file mode 100644 index 0000000..e9e8173 --- /dev/null +++ b/src/components/master-data/adjustment-reasons-client.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; + +import { useLocale } from "@/components/providers/locale-provider"; +import { PaginationFooter } from "@/components/shared/pagination-footer"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { usePagination } from "@/hooks/use-pagination"; +import type { ApiErrorResponse, DetailResponse } from "@/types/api"; +import type { AdjustmentReasonRecord, MasterStatus } from "@/types/master-data"; + +type FormValues = { + code: string; + name: string; + category: string; + status: MasterStatus; +}; + +const emptyForm: FormValues = { + code: "", + name: "", + category: "", + status: "ACTIVE" +}; + +export function AdjustmentReasonsClient() { + const { dict } = useLocale(); + const [items, setItems] = useState([]); + const [search, setSearch] = useState(""); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const { canEditCode } = useCurrentUser(); + const filteredItems = useMemo(() => { + const keyword = search.trim().toLowerCase(); + if (!keyword) return items; + return items.filter((item) => + [item.code, item.name, item.category].some((value) => value.toLowerCase().includes(keyword)) + ); + }, [items, search]); + const pagination = usePagination(filteredItems, 20); + + async function loadItems() { + setLoading(true); + setError(null); + try { + const response = await fetch("/api/v1/adjustment-reasons", { cache: "no-store" }); + const payload = (await response.json()) as { data: AdjustmentReasonRecord[] }; + if (!response.ok) throw new Error(dict.master.loadError); + setItems(payload.data); + } catch (err) { + setError(err instanceof Error ? err.message : dict.master.loadError); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadItems(); + }, []); + + function resetForm() { + setEditingId(null); + setForm(emptyForm); + setError(null); + } + + function startEdit(item: AdjustmentReasonRecord) { + setEditingId(item.id); + setForm({ + code: item.code, + name: item.name, + category: item.category, + status: item.status as MasterStatus + }); + setError(null); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const response = await fetch( + editingId ? `/api/v1/adjustment-reasons/${editingId}` : "/api/v1/adjustment-reasons", + { + method: editingId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form) + } + ); + const payload = + (await response.json()) as DetailResponse | ApiErrorResponse; + if (!response.ok) { + if ("errors" in payload && payload.errors) { + const firstError = Object.values(payload.errors)[0]?.[0]; + throw new Error(firstError ?? payload.message ?? dict.common.requestFailed); + } + throw new Error("message" in payload ? payload.message : dict.common.requestFailed); + } + resetForm(); + await loadItems(); + } catch (err) { + setError(err instanceof Error ? err.message : dict.master.saveError); + } finally { + setSubmitting(false); + } + } + + async function handleDelete(id: string) { + if (!window.confirm(`${dict.common.delete} adjustment reason ini?`)) return; + try { + const response = await fetch(`/api/v1/adjustment-reasons/${id}`, { + method: "DELETE" + }); + if (!response.ok) { + const payload = (await response.json()) as ApiErrorResponse; + throw new Error(payload.message); + } + if (editingId === id) resetForm(); + await loadItems(); + } catch (err) { + setError(err instanceof Error ? err.message : dict.master.deleteError); + } + } + + return ( +
+
+
+
+

{dict.master.overline}

+

+ {editingId + ? `${dict.common.edit} Alasan Penyesuaian` + : `${dict.master.add} Alasan Penyesuaian`} +

+

+ Master alasan untuk penyesuaian, penyusutan, kerusakan, dan stok opname. +

+
+ {editingId ? ( + + ) : null} +
+
+
+ setForm((current) => ({ ...current, code: value }))} + /> + setForm((current) => ({ ...current, name: value }))} + /> + setForm((current) => ({ ...current, category: value }))} + /> +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+
+
+
+

{dict.master.list} Alasan Penyesuaian

+

Master alasan untuk transaksi persediaan.

+
+
+ {items.length} {dict.master.dataCount} +
+
+ {!loading && items.length > 0 ? ( +
+ +
+ ) : null} + {loading ? ( +
{dict.common.loading}
+ ) : items.length === 0 ? ( +
+ {dict.master.noData} +
+ ) : filteredItems.length === 0 ? ( +
+ Tidak ada alasan penyesuaian yang cocok dengan pencarian. +
+ ) : ( + <> +
+ + + + + + + + + + + + {pagination.pagedItems.map((item) => ( + + + + + + + + ))} + +
{dict.master.code}{dict.master.name}Kategori{dict.master.status}{dict.common.actions}
{item.code}{item.name}{item.category} + + {item.status === "INACTIVE" ? dict.master.inactive : dict.master.active} + + +
+ + +
+
+
+ pagination.setPage((page) => Math.max(1, page - 1))} + onNext={() => pagination.setPage((page) => Math.min(pagination.totalPages, page + 1))} + /> + + )} +
+
+ ); +} + +function Field({ + label, + value, + onChange, + readOnly = false, + placeholder +}: { + label: string; + value: string; + onChange: (value: string) => void; + readOnly?: boolean; + placeholder?: string; +}) { + return ( + + ); +} diff --git a/src/components/master-data/agents-client.tsx b/src/components/master-data/agents-client.tsx new file mode 100644 index 0000000..7888039 --- /dev/null +++ b/src/components/master-data/agents-client.tsx @@ -0,0 +1,717 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; + +import { useLocale } from "@/components/providers/locale-provider"; +import { PaginationFooter } from "@/components/shared/pagination-footer"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { usePagination } from "@/hooks/use-pagination"; +import { formatCurrencyAmount } from "@/lib/formatters"; +import type { ApiErrorResponse, DetailResponse } from "@/types/api"; +import type { + AgentDetailRecord, + AgentRecord, + BankRecord, + ProfitShareSchemeRecord +} from "@/types/master-data"; + +type BankAccountForm = { + bank_id: string; + account_number: string; +}; + +type AgentForm = { + code: string; + name: string; + identity_type: "KTP" | "SIM" | "PASSPORT"; + identity_number: string; + mobile_phone: string; + email: string; + address: string; + notes: string; + join_date: string; + profit_share_scheme_id: string; + profit_share_balance: string; + capital_balance: string; + bank_accounts: BankAccountForm[]; +}; + +const emptyBankAccount = (): BankAccountForm => ({ + bank_id: "", + account_number: "" +}); + +const emptyForm = (): AgentForm => ({ + code: "", + name: "", + identity_type: "KTP", + identity_number: "", + mobile_phone: "", + email: "", + address: "", + notes: "", + join_date: new Date().toISOString().slice(0, 10), + profit_share_scheme_id: "", + profit_share_balance: "0", + capital_balance: "0", + bank_accounts: [emptyBankAccount()] +}); + +export function AgentsClient() { + const { dict, locale } = useLocale(); + const { canEditCode } = useCurrentUser(); + const [agents, setAgents] = useState([]); + const [search, setSearch] = useState(""); + const [banks, setBanks] = useState([]); + const [schemes, setSchemes] = useState([]); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [selectedAgentDetail, setSelectedAgentDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const filteredAgents = useMemo(() => { + const keyword = search.trim().toLowerCase(); + if (!keyword) return agents; + return agents.filter((agent) => + [ + agent.code, + agent.name, + agent.identity_type, + agent.identity_number, + agent.mobile_phone ?? "", + agent.email ?? "", + agent.address ?? "", + agent.profit_share_scheme.code, + agent.profit_share_scheme.name + ].some((value) => value.toLowerCase().includes(keyword)) + ); + }, [agents, search]); + const pagination = usePagination(filteredAgents, 20); + + async function loadData() { + setLoading(true); + setError(null); + + try { + const [agentsResponse, banksResponse, schemesResponse] = await Promise.all([ + fetch("/api/v1/agents", { cache: "no-store" }), + fetch("/api/v1/banks", { cache: "no-store" }), + fetch("/api/v1/profit-share-schemes", { cache: "no-store" }) + ]); + + const agentsPayload = (await agentsResponse.json()) as { data: AgentRecord[] }; + const banksPayload = (await banksResponse.json()) as { data: BankRecord[] }; + const schemesPayload = (await schemesResponse.json()) as { data: ProfitShareSchemeRecord[] }; + + if (!agentsResponse.ok || !banksResponse.ok || !schemesResponse.ok) { + throw new Error(dict.master.loadError); + } + + setAgents(agentsPayload.data); + setBanks(banksPayload.data.filter((bank) => bank.status === "ACTIVE")); + setSchemes(schemesPayload.data.filter((scheme) => scheme.status === "ACTIVE")); + } catch (err) { + setError(err instanceof Error ? err.message : dict.master.loadError); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, []); + + async function openDetail(id: string) { + setSelectedAgentId(id); + setDetailLoading(true); + + try { + const response = await fetch(`/api/v1/agents/${id}/detail`, { cache: "no-store" }); + const payload = (await response.json()) as DetailResponse | ApiErrorResponse; + + if (!response.ok || !("data" in payload)) { + throw new Error("message" in payload ? payload.message : dict.common.requestFailed); + } + + setSelectedAgentDetail(payload.data); + } catch (err) { + setError(err instanceof Error ? err.message : dict.common.requestFailed); + } finally { + setDetailLoading(false); + } + } + + function resetForm() { + setEditingId(null); + setForm(emptyForm()); + setError(null); + } + + function startEdit(agent: AgentRecord) { + setEditingId(agent.id); + setForm({ + code: agent.code, + name: agent.name, + identity_type: agent.identity_type, + identity_number: agent.identity_number, + mobile_phone: agent.mobile_phone ?? "", + email: agent.email ?? "", + address: agent.address ?? "", + notes: agent.notes ?? "", + join_date: agent.join_date, + profit_share_scheme_id: agent.profit_share_scheme.id, + profit_share_balance: String(agent.profit_share_balance), + capital_balance: String(agent.capital_balance), + bank_accounts: + agent.bank_accounts.length > 0 + ? agent.bank_accounts.map((account) => ({ + bank_id: account.bank_id, + account_number: account.account_number + })) + : [emptyBankAccount()] + }); + setError(null); + } + + function updateBankAccount(index: number, patch: Partial) { + setForm((current) => ({ + ...current, + bank_accounts: current.bank_accounts.map((account, itemIndex) => + itemIndex === index ? { ...account, ...patch } : account + ) + })); + } + + function addBankAccount() { + setForm((current) => ({ + ...current, + bank_accounts: [...current.bank_accounts, emptyBankAccount()] + })); + } + + function removeBankAccount(index: number) { + setForm((current) => ({ + ...current, + bank_accounts: + current.bank_accounts.length === 1 + ? current.bank_accounts + : current.bank_accounts.filter((_, itemIndex) => itemIndex !== index) + })); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + + try { + const response = await fetch(editingId ? `/api/v1/agents/${editingId}` : "/api/v1/agents", { + method: editingId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...form, + profit_share_balance: Number(form.profit_share_balance || 0), + capital_balance: Number(form.capital_balance || 0) + }) + }); + + const payload = (await response.json()) as DetailResponse | ApiErrorResponse; + + if (!response.ok) { + if ("errors" in payload && payload.errors) { + const firstError = Object.values(payload.errors)[0]?.[0]; + throw new Error(firstError ?? payload.message ?? dict.common.requestFailed); + } + + throw new Error("message" in payload ? payload.message : dict.common.requestFailed); + } + + resetForm(); + await loadData(); + if (selectedAgentId) { + await openDetail(selectedAgentId); + } + } catch (err) { + setError(err instanceof Error ? err.message : dict.master.saveError); + } finally { + setSubmitting(false); + } + } + + async function handleDelete(id: string) { + if (!window.confirm(`${dict.common.delete} agen ini?`)) return; + + try { + const response = await fetch(`/api/v1/agents/${id}`, { method: "DELETE" }); + if (!response.ok) { + const payload = (await response.json()) as ApiErrorResponse; + throw new Error(payload.message); + } + + if (editingId === id) resetForm(); + if (selectedAgentId === id) { + setSelectedAgentId(null); + setSelectedAgentDetail(null); + } + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : dict.master.deleteError); + } + } + + function getSchemeLabel(scheme: AgentRecord["profit_share_scheme"]) { + return `${scheme.code} - ${scheme.name}`; + } + + return ( +
+
+
+
+

{dict.master.overline}

+

+ {editingId ? `${dict.common.edit} Agen` : `${dict.master.add} Agen`} +

+

+ Master agen dengan identitas, skema bagi hasil, saldo berjalan, dan multi rekening bank. +

+
+ {editingId ? : null} +
+ +
+
+ setForm((current) => ({ ...current, code: value }))} + /> + setForm((current) => ({ ...current, name: value }))} /> + setForm((current) => ({ ...current, identity_type: value as AgentForm["identity_type"] }))} + options={[ + { value: "KTP", label: "KTP" }, + { value: "SIM", label: "SIM" }, + { value: "PASSPORT", label: locale === "id" ? "Paspor" : "Passport" } + ]} + /> + setForm((current) => ({ ...current, identity_number: value }))} /> + setForm((current) => ({ ...current, mobile_phone: value }))} /> + setForm((current) => ({ ...current, email: value }))} /> + setForm((current) => ({ ...current, join_date: value }))} /> + setForm((current) => ({ ...current, profit_share_balance: value }))} /> + setForm((current) => ({ ...current, capital_balance: value }))} /> +
+ setForm((current) => ({ ...current, profit_share_scheme_id: value }))} + options={schemes.map((scheme) => ({ + value: scheme.id, + label: `${scheme.code} - ${scheme.name} (${scheme.share_agent}% / ${scheme.share_company}%)` + }))} + placeholder={locale === "id" ? "Pilih skema bagi hasil" : "Choose profit share scheme"} + /> +
+
+ +