Compare commits

...

222 Commits

Author SHA1 Message Date
Rivaldi A N S 456070491f Merge branch 'fix/marketing' into 'development'
[FIX/FE] Marketing

See merge request mbugroup/lti-web-client!481
2026-05-18 07:43:05 +00:00
ValdiANS c12beca4d7 fix: recalculate qty if product change 2026-05-18 14:26:52 +07:00
ValdiANS 910981645b fix: remove unnecessary code 2026-05-18 14:25:19 +07:00
ValdiANS 82b5429d02 fix: update DeliveryOrderSchema validation, make all delivery_order should valid instead of some 2026-05-18 14:24:59 +07:00
ValdiANS 6c6f739fc0 fix: remove onAfterSubmit callback in useFormikErrorList 2026-05-18 14:20:30 +07:00
ValdiANS 001dafecb7 fix: adjust copywriting for approve button based on approval step number 2026-05-18 14:18:35 +07:00
Rivaldi A N S 4bb3ada779 Merge branch 'feat/server-side-sorting' into 'development'
[FEAT/FE] Server-Side Sorting

See merge request mbugroup/lti-web-client!480
2026-05-18 04:38:58 +00:00
ValdiANS 0b63dcb532 feat: implement server-side sorting in FinanceTable 2026-05-18 11:37:40 +07:00
Rivaldi A N S 23dd220b2f Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!479
2026-05-18 03:46:22 +00:00
ValdiANS 770c293257 fix: adjust empty_kandang type in BaseDailyChecklist 2026-05-18 10:25:27 +07:00
ValdiANS 3374ab4779 fix: show Tanggal Selesai Kandang Kosong if category is empty kandang 2026-05-18 10:25:10 +07:00
ValdiANS 7a668c0cf9 fix: adjust empty kandang condition check 2026-05-18 10:20:52 +07:00
Rivaldi A N S 14151f6f5a Merge branch 'feat/add-bank-name-in-supplier-customer' into 'development'
[FEAT/FE] Bank Name in Supplier & Customer

See merge request mbugroup/lti-web-client!478
2026-05-13 09:26:25 +00:00
ValdiANS 0275e66eda feat: add bank_name 2026-05-13 16:25:35 +07:00
ValdiANS 9bc5842493 feat: add bank name input 2026-05-13 16:25:25 +07:00
ValdiANS 4cad8aba64 feat: add bank name column 2026-05-13 16:25:13 +07:00
Rivaldi A N S 7b5af69dd1 Merge branch 'feat/server-side-sorting-purchasing-expense' into 'development'
[FEAT/FE] Server-Side Sorting Purchasing & Expense

See merge request mbugroup/lti-web-client!477
2026-05-13 08:49:54 +00:00
ValdiANS 2e179b74ba fix: add sort for PO number 2026-05-13 15:29:19 +07:00
ValdiANS fe2a2dfb43 fix: add loading state to approve modal 2026-05-13 15:29:03 +07:00
ValdiANS 910a36857e fix: pass the rest of secondaryButton props 2026-05-13 15:26:30 +07:00
ValdiANS 58ddd9b991 fix: set sortDescFirst false 2026-05-13 15:26:10 +07:00
Rivaldi A N S bb9c6ab969 Merge branch 'fix/marketing' into 'development'
[FIX/FE] Marketing

See merge request mbugroup/lti-web-client!476
2026-05-13 06:55:55 +00:00
ValdiANS ddffdd1b27 fix: adjust marketing_type default value 2026-05-13 13:46:59 +07:00
Rivaldi A N S f097620c4b Merge branch 'feat/server-side-sorting-purchasing-expense' into 'development'
[FEAT/FE] Server-Side Sorting Purchasing & Expense

See merge request mbugroup/lti-web-client!475
2026-05-13 04:18:52 +00:00
ValdiANS 280d790f0c fix: add created_at column 2026-05-13 10:51:46 +07:00
ValdiANS 3a2e74b559 feat: implement server-side sorting 2026-05-13 10:51:35 +07:00
Giovanni Gabriel Septriadi b9ef0fa338 Merge branch 'fix/pay' into 'development'
fix bop

See merge request mbugroup/lti-web-client!473
2026-05-12 08:54:42 +00:00
MacBook Air M1 6d8cdeffe9 fix bop 2026-05-12 15:53:16 +07:00
Rivaldi A N S 2e36247a1a Merge branch 'fix/product-stock-optimization' into 'development'
[FIX/FE] Product Stock Optimization

See merge request mbugroup/lti-web-client!472
2026-05-12 07:41:58 +00:00
ValdiANS 37cd990b4f fix: add empty_kandang to CATEGORY_LABELS 2026-05-12 14:38:50 +07:00
ValdiANS bdc7ac4d22 feat: only fetch when user scroll to the component 2026-05-12 14:38:19 +07:00
ValdiANS b6c2f36dd1 feat: implement filter for stock log table 2026-05-12 14:36:31 +07:00
ValdiANS 10cc4bee72 feat: create StockLogFilterModal component 2026-05-12 14:36:13 +07:00
Rivaldi A N S ff6bcf019b Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer To Laying

See merge request mbugroup/lti-web-client!471
2026-05-12 05:04:56 +00:00
ValdiANS bb0508d456 fix: adjust BaseTransferToLaying.sources.product_warehouse type 2026-05-12 12:03:19 +07:00
ValdiANS d6dd5e6709 fix: adjust remaining chicken UI layout 2026-05-12 12:02:51 +07:00
Rivaldi A N S 3c75a7631a Merge branch 'feat/expense-enhancement' into 'development'
[FEAT] Expense Enhancement

See merge request mbugroup/lti-web-client!470
2026-05-12 04:12:45 +00:00
ValdiANS e3d3e744b0 fix: add is_paid to BaseExpense 2026-05-12 11:09:40 +07:00
ValdiANS 5767a078d9 feat: implement paid off expense feature 2026-05-12 11:09:25 +07:00
ValdiANS 67c7e85ba8 fix: adjust swr key to fetch expense detail 2026-05-12 11:09:03 +07:00
ValdiANS c5a5582147 feat: create setExpensePaidOff method 2026-05-12 10:26:57 +07:00
Rivaldi A N S 46cb8a7d61 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!469
2026-05-11 09:54:31 +00:00
ValdiANS 0189733dec fix: add week and excess_days to BaseRecording type 2026-05-11 16:51:30 +07:00
ValdiANS d0c3581f57 fix: use checklistId param instead of date, kandang_id, and category when redirecting for edit 2026-05-11 16:51:14 +07:00
ValdiANS e7569b7448 fix: hit API when user click Simpan Draft/Submit and and empty kandang end date 2026-05-11 16:50:44 +07:00
ValdiANS 69b998a61a fix: update footer styling 2026-05-11 16:47:47 +07:00
ValdiANS c50c110005 fix: show excess day 2026-05-11 16:47:33 +07:00
Rivaldi A N S 3775bb6093 Merge branch 'feat/marketing-table-order' into 'development'
[FEAT/FE] Marketing Table Order

See merge request mbugroup/lti-web-client!467
2026-05-09 03:57:29 +00:00
ValdiANS 3dc64d01db chore: update server-side sorting pattern context 2026-05-09 10:56:39 +07:00
ValdiANS 2ed8ecbbb7 fix: pass manualSorting to Table 2026-05-09 10:55:57 +07:00
ValdiANS e5f6ef8a85 fix: show document name and use document path from the API response 2026-05-09 10:55:38 +07:00
Rivaldi A N S 7ff0891ad5 Merge branch 'feat/stock-log-export' into 'development'
[FEAT/FE] Stock Log Export

See merge request mbugroup/lti-web-client!466
2026-05-08 11:59:06 +00:00
ValdiANS a9a5098a21 fix: set default map for pageSize to limit 2026-05-08 18:58:25 +07:00
ValdiANS 7f9bb8e11d chore: remove unnecessary code 2026-05-08 18:58:13 +07:00
ValdiANS bef3f365bb feat: add stock log permission 2026-05-08 18:58:02 +07:00
ValdiANS a0e8c60082 chore: adjust styling 2026-05-08 18:57:37 +07:00
ValdiANS e7f378823c feat: implement export product stock log 2026-05-08 18:57:21 +07:00
Rivaldi A N S ba3cb98e2c Merge branch 'feat/marketing-table-order' into 'development'
[FEAT/FE] Marketing Table Order

See merge request mbugroup/lti-web-client!465
2026-05-08 09:30:39 +00:00
ValdiANS 7643645643 feat: implement server-side sorting 2026-05-08 16:25:51 +07:00
ValdiANS 3b1e7e3b03 feat: add server-side sorting pattern 2026-05-08 16:16:16 +07:00
Rivaldi A N S 725111dc0c Merge branch 'fix/recording' into 'development'
[FIX/FE] Recording Form

See merge request mbugroup/lti-web-client!464
2026-05-08 08:28:17 +00:00
ValdiANS 073d7eee03 chore: prettier format 2026-05-08 15:25:58 +07:00
ValdiANS cce5a8df43 fix: set stocks quantity to usage_amount + pending_qty 2026-05-08 15:25:48 +07:00
M1 AIR 978067ac6c Update env not slash 2026-05-07 15:08:33 +07:00
M1 AIR 6255367366 Update env 2026-05-07 14:15:17 +07:00
Rivaldi A N S af9cb8ec6b Merge branch 'fix/inventory-product' into 'development'
[FIX/FE] Inventory Product

See merge request mbugroup/lti-web-client!463
2026-05-06 03:53:42 +00:00
ValdiANS e0a1922ed4 fix: implement table filter 2026-05-06 10:31:00 +07:00
ValdiANS 4b5ad0dcab fix: show total item data 2026-05-06 10:23:22 +07:00
Rivaldi A N S ca62b31aa6 Merge branch 'fix/expense' into 'development'
[FIX/FE] Expense

See merge request mbugroup/lti-web-client!462
2026-05-06 03:03:10 +00:00
ValdiANS 4ec32c51b2 Merge branch 'fix/expense' of https://gitlab.com/mbugroup/lti-web-client into fix/expense 2026-05-06 10:02:04 +07:00
ValdiANS cdee616e18 fix: remove realization_date validation 2026-05-06 10:01:35 +07:00
ValdiANS 50378a2ee2 fix: remote realization_date validation 2026-05-06 09:49:13 +07:00
Rivaldi A N S ab093467c4 Merge branch 'fix/production' into 'development'
[FIX/FE] Production

See merge request mbugroup/lti-web-client!461
2026-05-05 09:46:25 +00:00
ValdiANS 79e41d8a6f fix: implement table persist state in recording filter 2026-05-05 16:10:57 +07:00
ValdiANS 35001ff422 fix: make depletion and egg optional 2026-05-05 16:10:44 +07:00
Rivaldi A N S 7026619249 Merge branch 'fix/production' into 'development'
[FIX/FE] Production

See merge request mbugroup/lti-web-client!460
2026-05-04 09:25:33 +00:00
ValdiANS 3945142966 fix: add formikFlockSource to useEffect dependencies to set flock source raw data 2026-05-04 16:24:21 +07:00
ValdiANS b19099cea2 fix: takeout export button 2026-05-04 16:23:35 +07:00
Rivaldi A N S f65593de25 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!459
2026-05-04 07:20:16 +00:00
ValdiANS a5f1a6ea75 fix: order select input options in ascending manner 2026-05-04 14:19:14 +07:00
ValdiANS 4e58f20ba3 fix: set timeout to 1 minute 2026-05-04 14:15:57 +07:00
Rivaldi A N S dc41d6ce73 Merge branch 'fix/system' into 'development'
[FIX][FE]: adjust get detail recording

See merge request mbugroup/lti-web-client!458
2026-05-04 05:21:35 +00:00
MacBook Air M1 8869c9df2c adjust get detail recording 2026-05-04 12:20:20 +07:00
Rivaldi A N S f2b3f2b584 Merge branch 'fix/purchasing' into 'development'
[FIX/FE] Purchasing

See merge request mbugroup/lti-web-client!457
2026-05-04 03:18:07 +00:00
ValdiANS 31cea258a7 fix: adjust delete click handler 2026-05-04 09:48:46 +07:00
Rivaldi A N S 53d7439300 Merge branch 'fix/recording' into 'development'
[FIX/FE] Recording

See merge request mbugroup/lti-web-client!455
2026-05-02 10:06:00 +00:00
ValdiANS 28a1852de8 fix: adjust stock, depletion, and egg select input 2026-05-02 17:04:59 +07:00
Rivaldi A N S ff92073d19 Merge branch 'fix/persist-filter' into 'development'
[FIX/FE] Persist Filter

See merge request mbugroup/lti-web-client!453
2026-04-30 10:02:56 +00:00
ValdiANS 6ffc2c2806 fix: adjust date filter layout 2026-04-30 16:56:47 +07:00
ValdiANS 9e402e373c fix: adjust filter submit handler 2026-04-30 16:56:12 +07:00
Rivaldi A N S 2e4c19b714 Merge branch 'fix/finance' into 'development'
[FIX/FE] Finance

See merge request mbugroup/lti-web-client!452
2026-04-30 08:02:36 +00:00
ValdiANS 039c926e2d fix: implement table filter persist state 2026-04-30 15:01:21 +07:00
ValdiANS e52ba7b394 fix: search input value and change handler 2026-04-30 15:01:11 +07:00
Rivaldi A N S 90dc7c80f2 Merge branch 'fix/finance' into 'development'
[FIX/FE] Finance

See merge request mbugroup/lti-web-client!451
2026-04-30 04:40:54 +00:00
ValdiANS 15c883ca73 fix: add created at column 2026-04-30 11:40:16 +07:00
Rivaldi A N S 47f74b8842 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!450
2026-04-30 04:20:13 +00:00
ValdiANS ef9009b304 fix: remove empty kandang date 2026-04-30 11:02:09 +07:00
M1 AIR ce25758a17 Revert "fixing devops"
This reverts commit 371b236e25.
2026-04-30 00:34:43 +07:00
M1 AIR 371b236e25 fixing devops 2026-04-30 00:21:44 +07:00
Rivaldi A N S a54dd1fa9e Merge branch 'fix/daily-marketing-export' into 'development'
[FIX/FE] Daily Marketing Export

See merge request mbugroup/lti-web-client!448
2026-04-29 08:56:11 +00:00
ValdiANS 31205a44f9 feat: add new context to CLAUDE.md 2026-04-29 15:55:22 +07:00
ValdiANS 3c9c55e049 fix: implement server-side export 2026-04-29 15:55:03 +07:00
Rivaldi A N S 7a4f93cf0c Merge branch 'fix/persist-filter' into 'development'
[FIX/FE] Persist Filter

See merge request mbugroup/lti-web-client!447
2026-04-29 08:13:19 +00:00
ValdiANS a738d58c37 feat: add new context to CLAUDE.md 2026-04-29 15:11:03 +07:00
ValdiANS 46daed8fc4 fix: persist table filter state in master data 2026-04-29 15:10:41 +07:00
ValdiANS 29347c24f4 fix: create TableFilterStateValue type 2026-04-29 15:10:25 +07:00
Adnan Zahir a0f603b707 Merge branch 'feat/toggle-negative-usage' into 'development'
fix: missing useRef

See merge request mbugroup/lti-web-client!445
2026-04-29 12:18:21 +07:00
Adnan Zahir 631e3959cd fix: missing useRef 2026-04-29 12:15:02 +07:00
Adnan Zahir 2a340a26f9 Merge branch 'feat/toggle-negative-usage' into 'development'
fix: recording wont accept ovk

See merge request mbugroup/lti-web-client!444
2026-04-29 11:23:14 +07:00
Adnan Zahir e75246ff8d fix: recording wont accept ovk 2026-04-29 11:22:07 +07:00
Rivaldi A N S 64aee33452 Merge branch 'fix/project-flock-form' into 'development'
[FIX/FE] Project Flock Form

See merge request mbugroup/lti-web-client!443
2026-04-28 07:50:09 +00:00
ValdiANS 1851f0e12f fix: add periode to project flock form values 2026-04-28 14:34:19 +07:00
Adnan Zahir 8b3f44708d Merge branch 'feat/toggle-negative-usage' into 'development'
fix: show product options from master instead of warehouse of migration mode

See merge request mbugroup/lti-web-client!442
2026-04-28 13:57:04 +07:00
Adnan Zahir a5fd97a175 fix: show product options from master instead of warehouse of migration mode 2026-04-28 13:55:08 +07:00
Adnan Zahir 2ee5d1f7bd Merge branch 'feat/toggle-negative-usage' into 'development'
feat: konfigurasi sistem toggle pemakaian pakan ovk negatif

See merge request mbugroup/lti-web-client!440
2026-04-28 11:23:28 +07:00
Adnan Zahir 6eb257705f feat: konfigurasi sistem toggle pemakaian pakan ovk negatif 2026-04-28 10:50:46 +07:00
Rivaldi A N S 9ea1d06972 Merge branch 'fix/project-flock-form' into 'development'
[FIX/FE] Project Flock Form

See merge request mbugroup/lti-web-client!439
2026-04-28 02:53:11 +00:00
ValdiANS ff8833b5b3 fix: adjust period change handler 2026-04-28 09:45:25 +07:00
Rivaldi A N S 2dd98fd7e3 Merge branch 'fix/project-flock-form' into 'development'
[FIX/FE] Project Flock Form

See merge request mbugroup/lti-web-client!438
2026-04-27 06:13:19 +00:00
ValdiANS 76fff98d9d fix: change NumberInput name from 'period' to 'periode' 2026-04-27 13:12:26 +07:00
Rivaldi A N S 18eeabd353 Merge branch 'fix/project-flock-form' into 'development'
[FIX/FE] Project Flock Form

See merge request mbugroup/lti-web-client!437
2026-04-27 05:04:26 +00:00
ValdiANS 06b5a97de3 chore: prettier format 2026-04-27 12:03:17 +07:00
ValdiANS 5cccc0b3c6 fix: set background color for shared image 2026-04-27 12:03:09 +07:00
ValdiANS 7ab9518a55 fix: change 'period' to 'periode' 2026-04-27 12:02:43 +07:00
Rivaldi A N S ac51229398 Merge branch 'fix/project-flock-form' into 'development'
[FIX/FE] Project Flock Form

See merge request mbugroup/lti-web-client!436
2026-04-27 04:02:09 +00:00
ValdiANS 5a2532a0fa Merge branch 'development' into fix/project-flock-form 2026-04-27 10:58:44 +07:00
ValdiANS f9d2a875e2 chore: prettier format 2026-04-27 10:49:07 +07:00
ValdiANS 6cf8e463c6 feat: create CLAUDE.md 2026-04-27 10:48:56 +07:00
ValdiANS 4206408db1 fix: enable custom period 2026-04-27 10:48:42 +07:00
ValdiANS ff2ed8757f fix: set fallback timeout to 30s 2026-04-27 10:43:30 +07:00
Adnan Zahir 0c5ee08f90 Merge branch 'codex/filter-improment' into 'development'
fix: nested modal

See merge request mbugroup/lti-web-client!434
2026-04-25 23:57:53 +07:00
Adnan Zahir bbf9581d3a fix: nested modal 2026-04-25 23:55:26 +07:00
Adnan Zahir 5830ab4c67 Merge branch 'codex/filter-improment' into 'development'
Codex/po date

See merge request mbugroup/lti-web-client!433
2026-04-25 22:50:31 +07:00
Adnan Zahir a1a0b71814 feat: editable po_date 2026-04-25 22:47:59 +07:00
Adnan Zahir 2b3b6b9549 feat: expose received_date in laporan pembelian 2026-04-25 22:24:39 +07:00
Adnan Zahir be3034a94e Merge branch 'codex/filter-improment' into 'development'
fix: pagination positioning

See merge request mbugroup/lti-web-client!431
2026-04-25 13:07:25 +07:00
Adnan Zahir a11d05e720 fix: pagination positioning 2026-04-25 13:06:44 +07:00
Rivaldi A N S cb454e7eb7 Merge branch 'feat/share-daily-checklist-to-wa' into 'development'
[FEAT/FE] Adjust Share Daily Checklist to Whatsapp

See merge request mbugroup/lti-web-client!429
2026-04-25 05:34:44 +00:00
Adnan Zahir a6d6c53069 Merge branch 'codex/filter-improment' into 'development'
feat: add more filters

See merge request mbugroup/lti-web-client!430
2026-04-25 12:27:49 +07:00
Adnan Zahir c875ebd951 feat: add more filters 2026-04-25 12:15:42 +07:00
ValdiANS a369386922 feat: add share to whatsapp after submitting daily checklist 2026-04-24 17:22:20 +07:00
ValdiANS b3198a44e9 uncomment pre-commit 2026-04-24 17:10:38 +07:00
ValdiANS b2dfb8fec6 fix: adjust share to whatsapp message 2026-04-24 17:10:11 +07:00
ValdiANS d4d77bb13a fix: add excluded fields in ButtonFilter 2026-04-24 16:22:46 +07:00
ValdiANS 7dfa5233f3 fix: adjust MarketingFilter type 2026-04-24 16:22:23 +07:00
ValdiANS 3d910f78db fix: set initial value to MarketingFilter 2026-04-24 16:22:15 +07:00
Rivaldi A N S 2dfac0be72 Merge branch 'feat/share-daily-checklist-to-wa' into 'development'
[FEAT/FE] Share Daily Checklist

See merge request mbugroup/lti-web-client!428
2026-04-24 05:35:32 +00:00
ValdiANS afe0d2161d feat: implement share daily checklist 2026-04-24 12:00:06 +07:00
ValdiANS 68c13c48c7 fix: adjust PurchaseFilter type 2026-04-23 16:38:31 +07:00
ValdiANS b9a1e94a29 fix: set timeout to 30s 2026-04-23 16:38:16 +07:00
ValdiANS d8c6a90c55 feat: add excludeKeysFromUrl to useTableFilter parameters 2026-04-23 16:38:02 +07:00
ValdiANS 4d01ad7d1d fix: hide phase selection, abk assignment, and activity checklist form when kandang is empty 2026-04-23 16:22:05 +07:00
ValdiANS c487e7f53e fix: persist purchase table and set initial value to PurchaseFilterModal 2026-04-23 16:21:45 +07:00
ValdiANS a316120a78 fix: add total selected items text to reject/approve button 2026-04-23 16:08:39 +07:00
ValdiANS 9af0537587 fix: change vendor column label to "Uraian" 2026-04-23 16:07:21 +07:00
ValdiANS f668bcecb8 fix: change "Kembali" button behavior from link to button 2026-04-23 16:06:48 +07:00
Rivaldi A N S a12b09eb5f Merge branch 'feat/expense-export' into 'development'
[FEAT/FE] Expense Export

See merge request mbugroup/lti-web-client!426
2026-04-23 03:06:07 +00:00
ValdiANS cfb96c45c9 fix: uncomment pre-commit 2026-04-23 09:55:00 +07:00
ValdiANS 747b0f9c2c feat: implement export all in expense and report expense 2026-04-23 09:54:20 +07:00
Adnan Zahir ee2f530d81 Merge branch 'codex/filter-improment' into 'development'
feat: filter improvement

See merge request mbugroup/lti-web-client!425
2026-04-23 00:19:16 +07:00
Adnan Zahir 617124efe4 feat: filter improvement 2026-04-23 00:18:10 +07:00
Rivaldi A N S c0337f4d67 Merge branch 'fix/marketing-export' into 'development'
[FIX/FE] Marketing Export

See merge request mbugroup/lti-web-client!424
2026-04-22 16:34:37 +00:00
ValdiANS e5dcca3408 fix: adjust MarketingApi.exportToExcel method 2026-04-22 23:32:58 +07:00
Rivaldi A N S f2b05856bb Merge branch 'feat/purchase-export' into 'development'
[FEAT/FE] Purchase Export

See merge request mbugroup/lti-web-client!423
2026-04-22 16:21:33 +00:00
ValdiANS 5d6aaace86 feat: implement purchase export to excel 2026-04-22 23:20:31 +07:00
Rivaldi A N S 9dcb3d7269 Merge branch 'fix/daily-checklist-empty-kandang-flag' into 'development'
[FIX/FE] Daily Checklist Empty Kandang Flag

See merge request mbugroup/lti-web-client!422
2026-04-22 15:58:01 +00:00
ValdiANS e96bb46cfd fix: add empty_kandang value in CATEGORY_LABELS 2026-04-22 22:50:38 +07:00
ValdiANS 37edc957d2 fix: implement empty kandang in daily checklist 2026-04-22 16:04:39 +07:00
Rivaldi A N S 60df577cc6 Merge branch 'fix/purchase-form' into 'development'
[FIX/FE] Purchase Form & Expense Filter

See merge request mbugroup/lti-web-client!421
2026-04-22 07:01:46 +00:00
ValdiANS e0e2b0c406 fix: load more location and vendors and adjust reset handler 2026-04-22 13:58:25 +07:00
ValdiANS 244be32b59 fix: adjust ExpensesFilterSchema for location and vendor select input 2026-04-22 13:56:59 +07:00
ValdiANS 1080a26f93 fix: search in location select input 2026-04-22 13:55:29 +07:00
Adnan Zahir c12bf92723 Merge branch 'schema/bulk-approve-marketings-expenses' into 'development'
Schema/bulk approve marketings expenses

See merge request mbugroup/lti-web-client!419
2026-04-22 11:49:45 +07:00
Adnan Zahir 5c5b49d0a9 fix: styling 2026-04-22 11:41:41 +07:00
Adnan Zahir b7f886b51e fix: mismatch dto marketings 2026-04-22 11:41:09 +07:00
Rivaldi A N S 587266e23d Merge branch 'feat/progress-input-export' into 'development'
[FEAT/FE] Progress Input Exporet

See merge request mbugroup/lti-web-client!418
2026-04-22 04:09:21 +00:00
ValdiANS 9293b6321f feat: implement purchase export progress input 2026-04-22 11:06:34 +07:00
ValdiANS ddfd1206a7 feat: implement recording export progress input 2026-04-22 11:06:15 +07:00
ValdiANS 75910960c5 feat: implement marketing export progress input 2026-04-22 11:06:03 +07:00
ValdiANS aae633edee feat: implement expense export progress input 2026-04-22 11:05:54 +07:00
Adnan Zahir f129329d52 Merge branch 'schema/bulk-approve-marketings-expenses' into 'development'
Schema/bulk approve marketings expenses

See merge request mbugroup/lti-web-client!417
2026-04-22 10:36:38 +07:00
Adnan Zahir 2afcc5d1c9 fix: styling 2026-04-22 10:36:03 +07:00
Adnan Zahir 7f578c5d03 fix: bulkApprovals method 2026-04-22 10:34:59 +07:00
Adnan Zahir 180b129550 Merge branch 'schema/bulk-approve-marketings-expenses' into 'development'
fix: schema update for bulk approve

See merge request mbugroup/lti-web-client!416
2026-04-22 10:14:25 +07:00
Adnan Zahir 8b2277c8c3 Merge branch 'development' into 'schema/bulk-approve-marketings-expenses'
# Conflicts:
#   src/services/api/expense.ts
2026-04-22 10:14:17 +07:00
Adnan Zahir 68f4562395 fix: schema update for bulk approve 2026-04-22 10:10:10 +07:00
Rivaldi A N S c374a4a4e9 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!415
2026-04-22 02:41:14 +00:00
ValdiANS bda66381b8 fix: remove conditional rendering for delete button 2026-04-22 09:38:56 +07:00
Rivaldi A N S 28adeee7bd Merge branch 'feat/bulk-approve-expense' into 'development'
[FEAT/FE] Bulk Approve Expense

See merge request mbugroup/lti-web-client!414
2026-04-21 18:16:08 +00:00
ValdiANS 727ac8ccdb feat: implement bulk approval in expense 2026-04-22 01:14:16 +07:00
Rivaldi A N S b77a8ef56f Merge branch 'feat/bulk-approve-sales-order' into 'development'
[FEAT/FE] Bulk Approve Sales Order

See merge request mbugroup/lti-web-client!413
2026-04-21 17:12:12 +00:00
ValdiANS 50e0ccd9e4 feat: implement bulk approval for SO DO 2026-04-22 00:10:22 +07:00
Rivaldi A N S e43a25307f Merge branch 'fix/project-flock' into 'development'
[FIX/FE] Project Flock

See merge request mbugroup/lti-web-client!412
2026-04-21 09:01:14 +00:00
ValdiANS db4750217e feat: create TableFilterStore type 2026-04-21 15:57:35 +07:00
ValdiANS 19793cdcd4 feat: create useTableFilterStore 2026-04-21 15:57:26 +07:00
ValdiANS 15bddc43e2 fix: implement persist to storage in useTableFilter 2026-04-21 15:57:15 +07:00
ValdiANS 3b7c7bb13f fix: persist table filter 2026-04-21 15:52:36 +07:00
ValdiANS 633cece581 fix: use session storage for useUiStore 2026-04-21 13:30:51 +07:00
ValdiANS e455d203cc fix: set searchKey to 'search' in useSelect 2026-04-21 13:30:34 +07:00
ValdiANS d2a5229282 fix: set fallback value for searchKey in url search params 2026-04-21 13:20:49 +07:00
Rivaldi A N S 2391d6ceeb Merge branch 'feat/daily-checklist-bulk-actions' into 'development'
[FEAT/FE] Daily Checklist Bulk Actions

See merge request mbugroup/lti-web-client!411
2026-04-20 09:22:17 +00:00
ValdiANS 4bb57ed0a0 feat: create bulkApprove and bulkReject method 2026-04-20 16:21:28 +07:00
ValdiANS 5bf3d32636 feat: implement bulk approve & reject 2026-04-20 16:21:17 +07:00
Rivaldi A N S c5a0cfe118 Merge branch 'fix/marketing-report' into 'development'
[FIX/FE] Marketing Report

See merge request mbugroup/lti-web-client!409
2026-04-19 18:15:30 +00:00
ValdiANS 898bbd57ec fix: pass page and pageSize to Table component 2026-04-20 01:14:10 +07:00
ValdiANS 5b5113de6e fix: add page and pageSize 2026-04-20 01:13:51 +07:00
ValdiANS 267a6f37cc chore: remove unnecessary code 2026-04-20 01:13:32 +07:00
Rivaldi A N S 7d4898c266 Merge branch 'feat/depreciation-report' into 'development'
[FEAT/FE] Depreciation Report

See merge request mbugroup/lti-web-client!408
2026-04-19 17:31:02 +00:00
ValdiANS f49822d03d fix: adjust ReportDepreciation type 2026-04-20 00:30:07 +07:00
ValdiANS aa4da686c6 fix: move and rename report to expense-report.ts 2026-04-20 00:29:55 +07:00
ValdiANS 5a668c469f chore: update ReportExpenseApi import path 2026-04-20 00:29:34 +07:00
ValdiANS 2ca733de97 fix: adjust ReportDepreciationTab content 2026-04-20 00:29:15 +07:00
ValdiANS 8afc1a6381 feat: create ReportDepreciationFilterModal component 2026-04-20 00:28:41 +07:00
ValdiANS d47142153e Merge branch 'development' into feat/depreciation-report 2026-04-19 21:57:46 +07:00
Rivaldi A N S 188385b638 Merge branch 'fix/recording-export' into 'development'
[FIX/FE] Recording Export

See merge request mbugroup/lti-web-client!406
2026-04-17 07:34:52 +00:00
ValdiANS 08aa79a06b fix: adjust limit to get all recording data in exportToExcel method 2026-04-17 14:07:52 +07:00
ValdiANS 16741aaa46 feat: create ReportDepreciation and ReportDepreciationSearchParams type 2026-04-17 13:27:21 +07:00
ValdiANS 93083c7d2a fix: create ReportDepreciationTab component 2026-04-17 13:27:05 +07:00
ValdiANS 8333b5138a fix: adjust ReportExpenseSkeleton and ReportSkeletonColumn type 2026-04-17 13:25:18 +07:00
ValdiANS c0ee2013f3 feat: add Laporan Depresiasi tab 2026-04-17 13:24:26 +07:00
Rivaldi A N S 022656cd80 Merge branch 'fix/daily-checklist-kandang' into 'development'
[FIX/FE] Daily Checklist Master Data Kandang

See merge request mbugroup/lti-web-client!405
2026-04-16 06:44:51 +00:00
ValdiANS 9d3c22fcf3 chore: remove unnecessary code 2026-04-16 13:42:52 +07:00
120 changed files with 8962 additions and 3301 deletions
+21 -2
View File
@@ -30,6 +30,10 @@ default:
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
- echo "NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV"
- echo "NEXT_PUBLIC_HELPDESK_URL=$NEXT_PUBLIC_HELPDESK_URL"
- echo "NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL=$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
- echo "NEXT_PUBLIC_S3_PUBLIC_BASE_URL=$NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
- echo "Building Next.js static export..."
- npx next build
- |
@@ -41,7 +45,11 @@ default:
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_APP_ENV": "$NEXT_PUBLIC_APP_ENV",
"NEXT_PUBLIC_HELPDESK_URL": "$NEXT_PUBLIC_HELPDESK_URL",
"NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL": "$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
"NEXT_PUBLIC_S3_PUBLIC_BASE_URL": "NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
}
EOF
artifacts:
@@ -142,6 +150,10 @@ build:dev:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'development'
NEXT_PUBLIC_HELPDESK_URL: 'https://dev-helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/'
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com'
deploy:dev:
<<: *deploy_template
@@ -170,6 +182,9 @@ build:staging:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'staging'
NEXT_PUBLIC_HELPDESK_URL: 'https://stg-helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://stg-dashboard-ho.mbugroup.id/'
deploy:staging:
<<: *deploy_template
@@ -185,7 +200,7 @@ deploy:staging:
url: https://stg-lti-erp.mbugroup.id
# ==========================================================
# ====== STAGING (Branch production) ======
# ====== (Branch production) ======
# ==========================================================
build:production:
<<: *build_template
@@ -198,6 +213,10 @@ build:production:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'production'
NEXT_PUBLIC_HELPDESK_URL: 'https://helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dashboard-ho.mbugroup.id/'
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/'
deploy:production:
<<: *deploy_template
+2 -1
View File
@@ -1,3 +1,4 @@
npm run format
npm run lint
npm run typecheck
npm run typecheck
git add .
+262
View File
@@ -0,0 +1,262 @@
# LTI Web Client
Next.js 15 (App Router) + React 19 + TypeScript front-end for the LTI ERP system.
## Tech stack
- **Framework:** Next.js 15.5 (App Router, Turbopack)
- **UI:** React 19, Tailwind CSS v4, Radix UI, daisyUI, lucide-react
- **State:** zustand
- **Forms:** Formik + Yup, react-hook-form
- **Data fetching:** axios + SWR (custom `httpClient` / `httpClientFetcher` in `src/services/http`)
- **Tables:** @tanstack/react-table
- **Reporting:** @react-pdf/renderer, jspdf, exceljs, xlsx, recharts
## Scripts
- `npm run dev` — lint + dev server (Turbopack)
- `npm run build` — production build
- `npm run lint` — ESLint
- `npm run typecheck``next typegen && tsc --noEmit`
- `npm run format` — Prettier
- `npm run pre-commit` — format + lint + typecheck + build (Husky pre-commit hook)
## Project structure
```
src/
app/ # Next.js App Router routes (one folder per feature)
components/
pages/{feature}/ # Page-specific components (mirrors src/app)
helper/ # Cross-cutting helpers (e.g. SuspenseHelper)
ui/ # Shared UI primitives
services/
api/ # API service classes (extend BaseApiService)
http/ # httpClient / httpClientFetcher
hooks/ # Service-level hooks
stores/ # zustand stores grouped by domain
types/api/ # Request/response types per feature
lib/ # Shared helpers (api-helper, formik-helper, utils, validation, …)
config/, styles/
```
## Feature development standard
**Always follow this order when adding a new feature.** This is a team convention — deviating creates churn in code review.
1. **Types** — Define payload and response types in `src/types/api/{feature}` (or `{feature}.d.ts` for small features).
2. **API service** — Add `src/services/api/{feature}.ts` exporting a class that extends `BaseApiService<T, CreatePayload, UpdatePayload>` from [src/services/api/base.ts](src/services/api/base.ts). Use a subfolder (e.g. `src/services/api/daily-checklist/`) when the feature has multiple resource classes.
3. **Page** — Create the route under `src/app/{feature}` and a matching `src/components/pages/{feature}` folder for its components.
4. **Component slicing** — Break the page UI into components inside `src/components/pages/{feature}`.
5. **Wire up the API** — Consume the service class from step 2 inside the page/components (often via SWR).
6. **Detail layout** — When a route reads URL params via `useSearchParams` (e.g. `/feature/detail?id=123`), add `src/app/{feature}/detail/layout.tsx` that wraps `children` in `<SuspenseHelper>` from `@/components/helper/SuspenseHelper`.
7. **Shared state** — Use zustand stores in `src/stores/{domain}` when state must cross component boundaries.
8. **Helpers** — Reuse from [src/lib](src/lib) first (`api-helper.ts`, `formik-helper.ts`, `utils/`, `validation/`, etc.). Add new helpers there.
### Reference implementations
`closing`, `finance`, `expense`, `production`, `inventory`, `marketing`, `master-data`, `purchase`, `report`, `daily-checklist`, `dashboard` — all live in both `src/app/{feature}` and `src/components/pages/{feature}` and follow the standard above.
## Conventions
- Path alias `@/` maps to `src/`.
- Detail pages that read `useSearchParams` MUST be wrapped in `<SuspenseHelper>` via a `layout.tsx` (see [src/app/finance/detail/layout.tsx](src/app/finance/detail/layout.tsx) for the canonical pattern).
- API service classes inherit CRUD methods (`getAll`, `getSingle`, etc.) from `BaseApiService` — extend the class for feature-specific endpoints rather than calling `httpClient` directly from components.
- Pre-commit runs format + lint + typecheck + build; do not bypass with `--no-verify`.
## Table filter persistence pattern
Data tables across all modules (master-data, inventory, finance, purchase, etc.) use `useTableFilter` with `persist: true` to persist filter state in localStorage. This allows users' search, pagination, and filter choices to survive page refreshes.
**Three core principles (apply to all table components):**
1. **Set formik initialValues from tableFilterState** (not hardcoded defaults)
- Ensures the filter modal displays currently active filters when opened
- Initialize directly from persisted state: `location: tableFilterState.locationFilter`
2. **Pass `true` as last parameter to updateFilter calls**
- `updateFilter('fieldName', value, true)` immediately persists to localStorage
- Resets pagination to page 1 when filters change (via SWR revalidation)
- Apply to: search handlers, filter form submissions, reset handlers
3. **Create custom formikResetHandler function**
- Clear each filter with `updateFilter(fieldName, defaultValue, true)`
- Call `formik.resetForm({ values: { ...defaults } })`
- Close the modal at the end
- Attach to both button `onClick` and form `onReset` handler
**Optimization: Avoid useCallback for simple handlers**
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
- Simple pass-through handlers don't need it:
```tsx
// ✅ Good: Simple handler without useCallback
const handleFilterChange = (val) => setFieldValue('location', val);
// ❌ Avoid: Unnecessary useCallback overhead
const handleFilterChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
```
**Best practice: Store OptionType objects directly, not IDs**
For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object).
```tsx
// Type the useTableFilter with the filter state structure
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: {
search: '',
locationFilter: undefined,
picFilter: undefined
},
paramMap: {
page: 'page',
pageSize: 'limit',
locationFilter: 'location_id',
picFilter: 'pic_id',
},
persist: true,
storeName: 'kandangs-table',
});
// Initialize formik with tableFilterState values (now typed OptionType objects)
const formik = useFormik<KandangFilterType>({
initialValues: {
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
},
...
});
// Handlers store the complete OptionType, not just the ID
const handleFilterLocationChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
// Use formik values directly in select inputs (no computed helpers needed)
<SelectInput
value={formik.values.location}
onChange={handleFilterLocationChange}
...
/>
```
**Apply this pattern to:**
- Any data table component across any module that needs persistent filters
- Master-data tables, inventory lists, finance reports, purchase orders, etc.
- Whenever users' filter/search/pagination choices should survive page refreshes
**Reference implementations:**
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
## Server-side sorting pattern
Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering.
**Four-part wiring:**
1. **Local sort state** — `const [sorting, setSorting] = useState<SortingState>([]);`
2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`):
```ts
initial: { sort_by: '', order_by: '' }
paramMap: { sort_by: 'sort_by', order_by: 'sort_order' }
```
3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`:
```ts
useEffect(() => {
if (sorting.length > 0) {
updateFilter('sort_by', sorting[0].id, true);
updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '');
updateFilter('order_by', '');
}
}, [sorting]);
```
4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting.
**Pass `sorting`, `setSorting`, and `manualSorting` to `<Table>`:**
```tsx
<Table sorting={sorting} setSorting={handleSortingChange} manualSorting={true} ... />
```
`manualSorting={true}` is required — without it TanStack Table still applies its own client-side sort pass on top of the server-sorted data, producing incorrect order.
**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx).
## Server-side file export pattern
All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries.
**Rule:** Export methods live in the API service class, not in components. Components only build the query string and call the service method.
### Service method (in `src/services/api/{feature}.ts`)
```ts
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel'); // or 'pdf', 'csv', etc.
params.set('page', '1');
params.set('limit', '99999999999');
const res = await httpClient<Blob>(`${this.basePath}?${params.toString()}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `filename-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
}
```
- Change `export=excel` → `export=pdf` (and the file extension) for PDF exports.
- Add one method per format; keep them side-by-side in the same service class.
### Component handler (in the page/tab component)
```ts
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const params = new URLSearchParams();
if (filterParams.foo) params.set('foo', filterParams.foo);
// ... map all active filter params ...
await FeatureApi.exportToExcel(params.toString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, searchValue]);
```
- Do **not** fetch all rows into the component to build the file — delegate entirely to the service method.
- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components.
**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx).
+2 -2
View File
@@ -15,8 +15,8 @@ const ExpenseDetailPage = () => {
const expenseId = searchParams.get('expenseId');
const { data: expense, isLoading: isLoadingExpense } = useSWR(
expenseId,
(id: number) => ExpenseApi.getSingle(id)
['expense-detail', expenseId],
([_, id]) => ExpenseApi.getSingle(Number(id))
);
if (!expenseId) {
@@ -0,0 +1,11 @@
import { SystemConfigContent } from '@/figma-make/components/pages/master-data/system-config/SystemConfigContent';
const SystemConfigPage = () => {
return (
<section className='w-full'>
<SystemConfigContent />
</section>
);
};
export default SystemConfigPage;
+1 -1
View File
@@ -226,7 +226,7 @@ const Pagination = ({
const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'>
Page {currentPage} of {totalPages}
Total Item: {totalItems} | Page {currentPage} of {totalPages}
</span>
);
+1
View File
@@ -173,6 +173,7 @@ const Table = <TData extends object>({
const tableOptions: TableOptions<TData> = {
columns,
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
defaultColumn: { sortDescFirst: false },
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
+1 -1
View File
@@ -523,7 +523,7 @@ const useSelect = <T,>(
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey]: inputValue ?? '',
[searchKey ? searchKey : 'search']: inputValue ?? '',
[pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString();
@@ -69,6 +69,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
secondaryButton={
secondaryButton
? {
...secondaryButton,
text: secondaryButton?.text ?? 'Tidak',
onClick: (e) => {
if (secondaryButton && secondaryButton?.onClick) {
@@ -1,7 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
@@ -10,16 +10,14 @@ import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestCont
import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent';
import { Expense } from '@/types/api/expense';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
interface ExpenseDetailProps {
initialValues?: Expense;
}
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter();
const [activeTab, setActiveTab] = useState<string>('request');
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const expenseDetailTabs = useMemo(() => {
const validTabs = [
@@ -50,8 +48,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
<section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'>
<Button
href={returnTo}
variant='link'
onClick={router.back}
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSWRConfig } from 'swr';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -19,6 +20,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper';
@@ -26,7 +28,7 @@ import {
UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
@@ -46,6 +48,11 @@ const ExpenseRequestContent = ({
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const { mutate } = useSWRConfig();
const refreshExpense = () => {
mutate((key) => Array.isArray(key) && key[0] === 'expense-detail');
};
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
useApprovalSteps({
@@ -95,17 +102,24 @@ const ExpenseRequestContent = ({
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 4;
const isExpensePaidOff = initialValues?.is_paid;
const showPaidOffButton =
!isExpensePaidOff && (initialValues?.latest_approval.step_number ?? 0) >= 4;
// Modal hooks
const deleteModal = useModal();
const completeModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const paidOffModal = useModal();
// Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const formik = useFormik<UploadRequestDocumentsFormValues>({
@@ -146,7 +160,31 @@ const ExpenseRequestContent = ({
rejectModal.openModal();
};
const paidOffClickHandler = () => {
paidOffModal.openModal();
};
// Modal confirm click handler
const confirmationModalPaidOffClickHandler = async () => {
setIsPaidOffLoading(true);
const paidOffResponse = await ExpenseApi.setExpensePaidOff(
initialValues?.id as number
);
if (isResponseSuccess(paidOffResponse)) {
toast.success('Berhasil menandai biaya operasional sebagai lunas!');
refreshExpense();
} else {
toast.error(
'Gagal menandai biaya operasional sebagai lunas!: ' +
paidOffResponse?.message
);
}
paidOffModal.closeModal();
setIsPaidOffLoading(false);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -388,6 +426,24 @@ const ExpenseRequestContent = ({
</RequirePermission>
)}
{showPaidOffButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
variant='outline'
color='success'
onClick={paidOffClickHandler}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:check-circle-outline'
width={24}
height={24}
/>
Tandai Lunas
</Button>
</RequirePermission>
)}
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
@@ -533,6 +589,19 @@ const ExpenseRequestContent = ({
/>
</td>
</tr>
<tr>
<th>Status Lunas</th>
<th>:</th>
<td>
<StatusBadge
color={initialValues?.is_paid ? 'primary' : 'warning'}
text={initialValues?.is_paid ? 'Lunas' : 'Belum Lunas'}
className={{
badge: 'w-fit whitespace-nowrap',
}}
/>
</td>
</tr>
<tr>
<th>Dokumen Pengajuan</th>
<th>:</th>
@@ -548,21 +617,15 @@ const ExpenseRequestContent = ({
<ul className='list-disc'>
{initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => {
const path = requestDocument.path.startsWith(
'/'
)
? requestDocument.path.slice(1)
: requestDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return (
<li key={requestDocumentIdx}>
<Link
href={documentUrl}
href={requestDocument.path}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{requestDocument.path}{' '}
{requestDocument.name}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
@@ -758,6 +821,21 @@ const ExpenseRequestContent = ({
onClick: confirmationModalRejectClickHandler,
}}
/>
<ConfirmationModal
ref={paidOffModal.ref}
type='success'
text='Apakah anda yakin ingin menandai biaya operasional ini sebagai lunas?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isPaidOffLoading,
onClick: confirmationModalPaidOffClickHandler,
}}
/>
</>
);
};
File diff suppressed because it is too large Load Diff
@@ -3,26 +3,60 @@ import * as yup from 'yup';
export type ExpensesFilterType = {
transaction_date: string | null;
realization_date: string | null;
location_id: string | null;
vendor_id: string | null;
location: { value: number; label: string } | null;
vendor: { value: number; label: string } | null;
category: { value: string; label: string } | null;
approval_status: { value: string; label: string } | null;
realization_status: { value: string; label: string } | null;
project_flock: { value: number; label: string } | null;
project_flock_kandang: { value: number; label: string } | null;
};
export const ExpensesFilterSchema = yup.object({
transaction_date: yup.string().nullable(),
realization_date: yup
.string()
.nullable()
.test(
'is-greater-or-equal-transaction',
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
function (value) {
const { transaction_date } = this.parent;
if (!transaction_date || !value) return true;
return new Date(value) >= new Date(transaction_date);
}
),
location_id: yup.string().nullable(),
vendor_id: yup.string().nullable(),
realization_date: yup.string().nullable(),
location: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
vendor: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
category: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
approval_status: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
realization_status: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
project_flock: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
project_flock_kandang: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
});
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -1,6 +1,6 @@
'use client';
import { RefObject } from 'react';
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
@@ -11,8 +11,11 @@ import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
import {
ExpensesFilterSchema,
ExpensesFilterValues,
@@ -31,64 +34,143 @@ const ExpensesFilterModal = ({
onSubmit,
onReset,
}: ExpensesFilterModalProps) => {
const [selectedLocationId, setSelectedLocationId] = useState<string>(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const closeModalHandler = () => {
ref.current?.close();
};
const categoryOptions = [
{ value: 'BOP', label: 'BOP' },
{ value: 'NON-BOP', label: 'NON-BOP' },
];
const approvalStatusOptions = [
{ value: 'HEAD_AREA', label: 'Approval Head Area' },
{ value: 'UNIT_VICE_PRESIDENT', label: 'Approval Unit Vice President' },
{ value: 'FINANCE', label: 'Approval Finance' },
{ value: 'REALISASI', label: 'Realisasi' },
{ value: 'SELESAI', label: 'Selesai' },
{ value: 'DITOLAK', label: 'Ditolak' },
];
const realizationStatusOptions = [
{ value: 'NOT_REALIZED', label: 'Belum Realisasi' },
{ value: 'REALIZED', label: 'Sudah Realisasi' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreVendors,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const {
setInputValue: setProjectFlockInputValue,
rawData: projectFlocksRawData,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<ExpensesFilterValues>({
enableReinitialize: true,
initialValues: initialValues || {
transaction_date: null,
realization_date: null,
location_id: null,
vendor_id: null,
location: null,
vendor: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
},
validationSchema: ExpensesFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
const locationValue = formik.values.location_id
? locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
: null;
useEffect(() => {
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.location]);
const vendorValue = formik.values.vendor_id
? vendorOptions.find(
(opt) => String(opt.value) === formik.values.vendor_id
) || null
: null;
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({
values: {
transaction_date: null,
realization_date: null,
location: null,
vendor: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('location_id', locationId);
const value = val as OptionType | null;
formik.setFieldValue('location', value);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(value?.value ? String(value.value) : '');
};
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
const vendorId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('vendor_id', vendorId);
formik.setFieldValue('vendor', val as OptionType | null);
};
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
return (
<Modal
ref={ref}
@@ -98,7 +180,7 @@ const ExpensesFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -121,49 +203,41 @@ const ExpensesFilterModal = ({
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<span className='py-2 text-xs font-semibold'>Tanggal</span>
<div className='flex flex-row items-center gap-1.5'>
<DateInput
name='transaction_date'
placeholder='Tanggal Transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='realization_date'
placeholder='Tanggal Realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
</div>
{formik.touched.realization_date &&
formik.errors.realization_date && (
<span className='text-xs text-error'>
{formik.errors.realization_date}
</span>
)}
</div>
<DateInput
name='transaction_date'
label='Tanggal Transaksi'
placeholder='Tanggal Transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<DateInput
name='realization_date'
label='Tanggal Realisasi'
placeholder='Tanggal Realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationValue}
value={formik.values.location}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
@@ -173,14 +247,87 @@ const ExpensesFilterModal = ({
label='Vendor'
placeholder='Pilih Vendor'
options={vendorOptions}
value={vendorValue}
value={formik.values.vendor}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreVendors}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={formik.values.category}
onChange={(val) =>
formik.setFieldValue('category', val as OptionType | null)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status BOP'
placeholder='Pilih Status BOP'
options={approvalStatusOptions}
value={formik.values.approval_status}
onChange={(val) =>
formik.setFieldValue('approval_status', val as OptionType | null)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status Pencairan'
placeholder='Pilih Status Pencairan'
options={realizationStatusOptions}
value={formik.values.realization_status}
onChange={(val) =>
formik.setFieldValue(
'realization_status',
val as OptionType | null
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={formik.values.project_flock}
onChange={(val) => {
formik.setFieldValue('project_flock', val as OptionType | null);
formik.setFieldValue('project_flock_kandang', null);
}}
onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlockOptions}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
val as OptionType | null
)
}
isClearable
isDisabled={!formik.values.project_flock}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
+197 -151
View File
@@ -1,13 +1,12 @@
'use client';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import React, { useEffect, useMemo, useState } from 'react';
import {
CellContext,
ColumnDef,
SortingState,
Updater,
} from '@tanstack/react-table';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
@@ -39,7 +38,7 @@ import PopoverContent from '@/components/popover/PopoverContent';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import RequirePermission from '@/components/helper/RequirePermission';
import { useUiStore } from '@/stores/ui/ui.store';
import ButtonFilter from '@/components/helper/ButtonFilter';
import {
FinanceTableFilterSchema,
FinanceTableFilterValues,
@@ -176,9 +175,6 @@ const RowOptionsMenu = ({
};
const FinanceTable = () => {
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
const previousPathRef = useRef<string | null>(null);
const {
state: tableFilterState,
updateFilter,
@@ -187,14 +183,18 @@ const FinanceTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
transactionTypes: '',
bankIds: '',
customerIds: '',
supplierIds: '',
sortBy: '',
sort_by: '',
orderBy: '',
startDate: '',
endDate: '',
bankNames: '',
customerNames: '',
supplierNames: '',
},
paramMap: {
page: 'page',
@@ -203,10 +203,14 @@ const FinanceTable = () => {
bankIds: 'bank_ids',
customerIds: 'customer_ids',
supplierIds: 'supplier_ids',
sortBy: 'sort_date',
sort_by: 'sort_by',
orderBy: 'sort_order',
startDate: 'start_date',
endDate: 'end_date',
},
excludeKeysFromUrl: ['bankNames', 'customerNames', 'supplierNames'],
persist: true,
storeName: 'finance-table',
});
// ===== FILTER MODAL STATE =====
@@ -235,7 +239,7 @@ const FinanceTable = () => {
// ===== Formik for Filter =====
const filterFormik = useFormik<FinanceTableFilterValues>({
initialValues: {
search: searchValue,
search: tableFilterState.search || '',
transaction_types: '',
bank_ids: '',
customer_ids: '',
@@ -245,29 +249,48 @@ const FinanceTable = () => {
end_date: '',
},
validationSchema: FinanceTableFilterSchema,
enableReinitialize: true,
onSubmit: (values) => {
updateFilter('search', values.search);
setSearchValue(values.search);
updateFilter('transactionTypes', values.transaction_types);
updateFilter('bankIds', values.bank_ids);
updateFilter('customerIds', values.customer_ids);
updateFilter('supplierIds', values.supplier_ids);
updateFilter('sortBy', values.sort_by);
updateFilter('startDate', values.start_date);
updateFilter('endDate', values.end_date);
onSubmit: (values, { setSubmitting }) => {
updateFilter('search', values.search, true);
updateFilter('transactionTypes', values.transaction_types, true);
updateFilter('bankIds', values.bank_ids, true);
updateFilter('customerIds', values.customer_ids, true);
updateFilter('supplierIds', values.supplier_ids, true);
updateFilter('sort_by', values.sort_by, true);
updateFilter('startDate', values.start_date, true);
updateFilter('endDate', values.end_date, true);
// Save display names for restoration on modal reopen
const toNames = (val: OptionType | OptionType[] | null) =>
val
? (Array.isArray(val) ? val : [val])
.map((o) => String(o.label))
.join(',')
: '';
updateFilter('bankNames', toNames(selectedBank), true);
updateFilter('customerNames', toNames(selectedCustomerId), true);
updateFilter('supplierNames', toNames(selectedSupplierId), true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
updateFilter('search', '', true);
updateFilter('transactionTypes', '', true);
updateFilter('bankIds', '', true);
updateFilter('customerIds', '', true);
updateFilter('supplierIds', '', true);
updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
updateFilter('startDate', '', true);
updateFilter('endDate', '', true);
updateFilter('bankNames', '', true);
updateFilter('customerNames', '', true);
updateFilter('supplierNames', '', true);
filterModal.closeModal();
},
});
@@ -320,40 +343,10 @@ const FinanceTable = () => {
});
}, [bankOptions, bankRawData]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.transactionTypes) count += 1;
if (tableFilterState.bankIds) count += 1;
if (tableFilterState.customerIds) count += 1;
if (tableFilterState.supplierIds) count += 1;
if (tableFilterState.sortBy) count += 1;
if (tableFilterState.startDate) count += 1;
if (tableFilterState.endDate) count += 1;
return count;
}, [
tableFilterState.transactionTypes,
tableFilterState.bankIds,
tableFilterState.customerIds,
tableFilterState.supplierIds,
tableFilterState.sortBy,
tableFilterState.startDate,
tableFilterState.endDate,
]);
const hasFilters = activeFiltersCount > 0;
// ===== Handler =====
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value, true);
};
const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null
@@ -409,6 +402,26 @@ const FinanceTable = () => {
);
};
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.orderBy === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('orderBy', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
}
};
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const endDate = filterFormik.values.end_date;
@@ -469,28 +482,74 @@ const FinanceTable = () => {
};
const handleFilterModalOpen = () => {
// Restore transaction types from stored comma-separated IDs
const txIds = tableFilterState.transactionTypes
? tableFilterState.transactionTypes.split(',')
: [];
const restoredTxTypes = FINANCE_TRANSACTION_TYPE_OPTIONS.filter((opt) =>
txIds.includes(String(opt.value))
);
setSelectedTransactionType(restoredTxTypes.length ? restoredTxTypes : null);
// Restore banks from stored IDs and names
const bankIdList = tableFilterState.bankIds
? tableFilterState.bankIds.split(',')
: [];
const bankNameList = tableFilterState.bankNames
? tableFilterState.bankNames.split(',')
: [];
const restoredBanks = bankIdList.map((id, i) => ({
value: id,
label: bankNameList[i] || id,
}));
setSelectedBank(restoredBanks.length ? restoredBanks : null);
// Restore customers from stored IDs and names
const customerIdList = tableFilterState.customerIds
? tableFilterState.customerIds.split(',')
: [];
const customerNameList = tableFilterState.customerNames
? tableFilterState.customerNames.split(',')
: [];
const restoredCustomers = customerIdList.map((id, i) => ({
value: id,
label: customerNameList[i] || id,
}));
setSelectedCustomerId(restoredCustomers.length ? restoredCustomers : null);
// Restore suppliers from stored IDs and names
const supplierIdList = tableFilterState.supplierIds
? tableFilterState.supplierIds.split(',')
: [];
const supplierNameList = tableFilterState.supplierNames
? tableFilterState.supplierNames.split(',')
: [];
const restoredSuppliers = supplierIdList.map((id, i) => ({
value: id,
label: supplierNameList[i] || id,
}));
setSelectedSupplierId(restoredSuppliers.length ? restoredSuppliers : null);
// Restore sort by
const restoredSortBy =
sortByOptions.find(
(opt) => String(opt.value) === tableFilterState.sort_by
) || null;
setSelectedSortBy(restoredSortBy);
// Restore formik values
filterFormik.setValues({
search: tableFilterState.search || '',
transaction_types: tableFilterState.transactionTypes || '',
bank_ids: tableFilterState.bankIds || '',
customer_ids: tableFilterState.customerIds || '',
supplier_ids: tableFilterState.supplierIds || '',
sort_by: tableFilterState.sort_by || '',
start_date: tableFilterState.startDate || '',
end_date: tableFilterState.endDate || '',
});
filterModal.openModal();
filterFormik.validateForm();
};
const resetFilterHandler = () => {
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
filterFormik.resetForm();
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
};
const confirmationModalDeleteClickHandler = async () => {
@@ -509,10 +568,12 @@ const FinanceTable = () => {
{
header: 'ID',
accessorKey: 'payment_code',
enableSorting: true,
},
{
header: 'References Number',
accessorKey: 'reference_number',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.reference_number;
return <span>{value ?? '-'}</span>;
@@ -521,6 +582,7 @@ const FinanceTable = () => {
{
header: 'Jenis Transaksi',
accessorKey: 'transaction_type',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.transaction_type
.split('_')
@@ -530,7 +592,8 @@ const FinanceTable = () => {
},
{
header: 'Pihak',
accessorFn: (finance: Finance) => finance.party?.name,
accessorKey: 'customer_name',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party?.id) {
return <span>{props.row.original.party?.name}</span>;
@@ -539,13 +602,23 @@ const FinanceTable = () => {
},
},
{
header: 'Tanggal',
accessorFn: (finance: Finance) =>
formatDate(finance.payment_date, 'DD MMM YYYY'),
header: 'Tanggal Pembayaran',
accessorKey: 'payment_date',
enableSorting: true,
cell: (props) =>
formatDate(props.row.original.payment_date, 'DD MMM YYYY'),
},
{
header: 'Tanggal Dibuat',
accessorKey: 'created_at',
enableSorting: true,
cell: (props) =>
formatDate(props.row.original.created_at, 'DD MMM YYYY'),
},
{
header: 'Metode Pembayaran',
accessorKey: 'payment_method',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.payment_method.split('_').join(' ');
return <span>{formatTitleCase(value)}</span>;
@@ -553,20 +626,26 @@ const FinanceTable = () => {
},
{
header: 'Bank',
accessorFn: (finance: Finance) =>
finance.bank
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`
accessorKey: 'bank',
enableSorting: true,
cell: (props) =>
props.row.original.bank
? `${props.row.original.bank?.alias} - ${props.row.original.bank?.account_number} - ${props.row.original.bank?.owner}`
: '-',
},
{
header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) =>
formatCurrency(Math.abs(finance.expense_amount)),
accessorKey: 'expense_amount',
enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.expense_amount)),
},
{
header: 'Pemasukan (Rp)',
accessorFn: (finance: Finance) =>
formatCurrency(Math.abs(finance.income_amount)),
accessorKey: 'income_amount',
enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.income_amount)),
},
{
header: 'Aksi',
@@ -605,27 +684,6 @@ const FinanceTable = () => {
};
}, [dateErrorShown]);
useEffect(() => {
previousPathRef.current = window.location.pathname;
return () => {
const currentPath = window.location.pathname;
const isCurrentPathFinance = currentPath.includes('/finance');
const isPreviousPathFinance =
previousPathRef.current?.includes('/finance');
if (isPreviousPathFinance && !isCurrentPathFinance) {
resetSearchValue();
}
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [resetSearchValue, dateErrorShown]);
return (
<>
<div className='w-full'>
@@ -687,25 +745,20 @@ const FinanceTable = () => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'orderBy',
'bankNames',
'customerNames',
'supplierNames',
]}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
className='px-3 py-2.5'
/>
</div>
</div>
@@ -741,6 +794,9 @@ const FinanceTable = () => {
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
@@ -874,19 +930,9 @@ const FinanceTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
filterFormik.resetForm();
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
resetFilterHandler();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -7,8 +7,7 @@ import {
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useFormik } from 'formik';
@@ -25,7 +24,6 @@ import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -100,25 +98,31 @@ const RowOptionsMenu = ({
};
const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
productCategorySort: string;
productSort: string;
warehouseSort: string;
stockSort: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
transactionTypeFilter?: OptionType<string>;
}>({
initial: {
search: '',
productCategorySort: '',
productSort: '',
warehouseSort: '',
stockSort: '',
productFilter: '',
warehouseFilter: '',
transactionTypeFilter: '',
productFilter: undefined,
warehouseFilter: undefined,
transactionTypeFilter: undefined,
},
paramMap: {
page: 'page',
@@ -131,6 +135,8 @@ const InventoryAdjustmentTable = () => {
warehouseFilter: 'warehouse_id',
transactionTypeFilter: 'transaction_type',
},
persist: true,
storeName: 'inventory-adjustment-table',
});
// ===== FILTER MODAL STATE =====
@@ -139,22 +145,27 @@ const InventoryAdjustmentTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<AdjustmentFilterType>({
initialValues: {
product_id: null,
warehouse: null,
transaction_type: null,
product: tableFilterState.productFilter,
warehouse: tableFilterState.warehouseFilter,
transaction_type: tableFilterState.transactionTypeFilter,
},
validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', String(values.warehouse?.value) || '');
updateFilter('transactionTypeFilter', values.transaction_type || '');
updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse || undefined, true);
updateFilter(
'transactionTypeFilter',
values.transaction_type || undefined,
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
updateFilter('transactionTypeFilter', '');
updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', undefined, true);
updateFilter('transactionTypeFilter', undefined, true);
filterModal.closeModal();
},
});
@@ -193,14 +204,9 @@ const InventoryAdjustmentTable = () => {
}, []);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product', val);
};
const handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
@@ -208,38 +214,20 @@ const InventoryAdjustmentTable = () => {
formik.setFieldValue('warehouse', val);
};
const handleFilterTransactionTypeChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const type = val as OptionType | null;
const typeValue = type?.value ? String(type.value) : null;
formik.setFieldValue('transaction_type', typeValue);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find(
(opt) => String(opt.value) === formik.values.product_id
) || null
);
}, [formik.values.product_id, productOptions]);
const transactionTypeValue = useMemo(() => {
if (!formik.values.transaction_type) return null;
return (
transactionTypeOptions.find(
(opt) => String(opt.value) === formik.values.transaction_type
) || null
);
}, [formik.values.transaction_type, transactionTypeOptions]);
const handleFilterTransactionTypeChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('transaction_type', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
transaction_type: tableFilterState.transactionTypeFilter ?? undefined,
});
filterModal.openModal();
formik.validateForm();
};
const {
@@ -276,17 +264,8 @@ const InventoryAdjustmentTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-adjustment-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
@@ -507,6 +486,8 @@ const InventoryAdjustmentTable = () => {
'productSort',
'warehouseSort',
'stockSort',
'productName',
'warehouseName',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
@@ -596,7 +577,7 @@ const InventoryAdjustmentTable = () => {
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
value={formik.values.product}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
@@ -620,7 +601,7 @@ const InventoryAdjustmentTable = () => {
label='Tipe Transaksi'
placeholder='Pilih Tipe Transaksi'
options={transactionTypeOptions}
value={transactionTypeValue}
value={formik.values.transaction_type}
onChange={handleFilterTransactionTypeChange}
isClearable
className={{ wrapper: 'w-full' }}
@@ -630,13 +611,9 @@ const InventoryAdjustmentTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,14 +1,23 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const AdjustmentFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
transaction_type: string().nullable(),
export const AdjustmentFilterSchema = Yup.object().shape({
product: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
transaction_type: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type AdjustmentFilterType = {
product_id: string | null;
transaction_type: string | null;
warehouse: OptionType<number> | null;
product?: OptionType<string>;
warehouse?: OptionType<string>;
transaction_type?: OptionType<string>;
};
@@ -1,14 +1,7 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR, { mutate } from 'swr';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik';
@@ -20,7 +13,6 @@ import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import Button from '@/components/Button';
@@ -108,20 +100,21 @@ const RowOptionsMenu = ({
};
const MovementTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
}>({
initial: {
search: '',
productFilter: '',
warehouseFilter: '',
productFilter: undefined,
warehouseFilter: undefined,
},
paramMap: {
page: 'page',
@@ -129,6 +122,8 @@ const MovementTable = () => {
productFilter: 'product_id',
warehouseFilter: 'warehouse_id',
},
persist: true,
storeName: 'movement-table',
});
// ===== FILTER MODAL STATE =====
@@ -137,19 +132,20 @@ const MovementTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<MovementFilterType>({
initialValues: {
product_id: null,
warehouse_id: null,
product: tableFilterState.productFilter,
warehouse: tableFilterState.warehouseFilter,
},
validationSchema: MovementFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', undefined, true);
filterModal.closeModal();
},
});
@@ -180,47 +176,23 @@ const MovementTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product', val);
};
const handleFilterWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const warehouse = val as OptionType | null;
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
formik.setFieldValue('warehouse_id', warehouseId);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find(
(opt) => String(opt.value) === formik.values.product_id
) || null
);
}, [formik.values.product_id, productOptions]);
const warehouseIdValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find(
(opt) => String(opt.value) === formik.values.warehouse_id
) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
const handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('warehouse', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
});
filterModal.openModal();
formik.validateForm();
};
const [sorting, setSorting] = useState<SortingState>([]);
@@ -255,17 +227,8 @@ const MovementTable = () => {
}
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('movement-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const movementColumns: ColumnDef<Movement>[] = useMemo(
@@ -464,7 +427,7 @@ const MovementTable = () => {
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
value={formik.values.product}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
@@ -476,7 +439,7 @@ const MovementTable = () => {
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={warehouseIdValue}
value={formik.values.warehouse}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
@@ -489,13 +452,9 @@ const MovementTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,11 +1,18 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const MovementFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
export const MovementFilterSchema = Yup.object().shape({
product: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type MovementFilterType = {
product_id: string | null;
warehouse_id: string | null;
product?: OptionType<string>;
warehouse?: OptionType<string>;
};
@@ -4,17 +4,23 @@ import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Modal, { useModal } from '@/components/Modal';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { ProductCategoryApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryProduct } from '@/types/api/inventory/product';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
@@ -71,25 +77,79 @@ const RowOptionsMenu = ({
};
const InventoryProductTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
}>({
initial: {
search: '',
categoryFilter: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
categoryFilter: 'product_category_id',
},
persist: true,
storeName: 'inventory-product-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<{ category?: OptionType<string> }>({
initialValues: { category: tableFilterState.categoryFilter },
validationSchema: Yup.object().shape({
category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}),
onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('categoryFilter', undefined, true);
filterModal.closeModal();
},
});
// ===== CATEGORY OPTIONS =====
const {
setInputValue: setCategoryInputValue,
options: categoryOptions,
isLoadingOptions: isLoadingCategoryOptions,
loadMore: loadMoreCategories,
} = useSelect<ProductCategory>(
filterModal.open ? ProductCategoryApi.basePath : null,
'id',
'name',
'search'
);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
category: tableFilterState.categoryFilter ?? undefined,
});
filterModal.openModal();
};
const handleFilterCategoryChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('category', val);
};
const [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR(
@@ -97,17 +157,8 @@ const InventoryProductTable = () => {
InventoryProductApi.getAllFetcher
);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const columns: ColumnDef<InventoryProduct>[] = useMemo(
@@ -182,96 +233,163 @@ const InventoryProductTable = () => {
);
return (
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.product_stock.create'>
<Button
href='/inventory/product/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Product
</Button>
</RequirePermission>
</div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
<>
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.product_stock.create'>
<Button
href='/inventory/product/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Product
</Button>
</RequirePermission>
</div>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? (
<div className='p-3'>
<InventoryProductTableSkeleton
columns={columns}
icon={
{/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon
icon='heroicons:document-text'
className='text-white'
icon='heroicons:magnifying-glass'
width={20}
height={20}
/>
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
) : (
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.data
: []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? (
<div className='p-3'>
<InventoryProductTableSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.data
: []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Kategori Produk'
placeholder='Pilih Kategori'
options={categoryOptions}
value={formik.values.category}
onChange={handleFilterCategoryChange}
onInputChange={setCategoryInputValue}
isLoading={isLoadingCategoryOptions}
isClearable
onMenuScrollToBottom={loadMoreCategories}
className={{ wrapper: 'w-full' }}
/>
</div>
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
@@ -1,8 +1,16 @@
'use client';
import Card from '@/components/Card';
import { OptionType } from '@/components/input/SelectInput';
import { FormHeader } from '@/components/helper/form/FormHeader';
import ButtonFilter from '@/components/helper/ButtonFilter';
import RequirePermission from '@/components/helper/RequirePermission';
import { useModal } from '@/components/Modal';
import StockLogFilterModal from '@/components/pages/inventory/product/detail/StockLogFilterModal';
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react';
@@ -11,17 +19,34 @@ const InventoryProductDetail = ({
}: {
inventoryProduct?: InventoryProduct;
}) => {
const stockLogs = useMemo(() => {
return (
inventoryProduct?.product_warehouses?.flatMap((warehouse) =>
warehouse.stock_logs.map((log) => ({
...log,
warehouse_name: warehouse.warehouse_name,
warehouse_id: warehouse.warehouse_id,
}))
) || []
);
}, [inventoryProduct]);
const filterModal = useModal();
const { state: filterState, updateFilter } = useTableFilter<{
warehouse_ids: OptionType<number>[];
}>({
initial: {
warehouse_ids: [],
},
persist: true,
storeName: 'inventory-product-stock-log-filter',
});
const filteredProductWarehouses = useMemo(() => {
const warehouses = inventoryProduct?.product_warehouses ?? [];
if (!filterState.warehouse_ids?.length) return warehouses;
const selectedIds = new Set(filterState.warehouse_ids.map((w) => w.value));
return warehouses.filter((pw) => selectedIds.has(pw.warehouse_id));
}, [inventoryProduct?.product_warehouses, filterState.warehouse_ids]);
const filterSubmitHandler = (values: {
warehouse_ids: OptionType<number>[];
}) => {
updateFilter('warehouse_ids', values.warehouse_ids, true);
};
const filterResetHandler = () => {
updateFilter('warehouse_ids', [], true);
};
return (
<div className='flex flex-col gap-4 p-4'>
@@ -114,7 +139,29 @@ const InventoryProductDetail = ({
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
/>
<StockLogTable stockLogs={stockLogs} />
<RequirePermission permissions={'lti.inventory.stock_log.list'}>
<div className='flex justify-end'>
<ButtonFilter
values={{ warehouse_ids: filterState.warehouse_ids }}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
</div>
{filteredProductWarehouses.map((productWarehouse) => (
<StockLogTable
key={productWarehouse.id}
productWarehouse={productWarehouse}
/>
))}
</RequirePermission>
<StockLogFilterModal
ref={filterModal.ref}
productWarehouses={inventoryProduct?.product_warehouses ?? []}
initialValues={{ warehouse_ids: filterState.warehouse_ids }}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
</div>
);
};
@@ -0,0 +1,115 @@
'use client';
import Button from '@/components/Button';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { OptionType } from '@/components/input/SelectInput';
import Modal from '@/components/Modal';
import { ProductWarehouseStock } from '@/types/api/inventory/product';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import { RefObject, useCallback } from 'react';
interface StockLogFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
productWarehouses: ProductWarehouseStock[];
initialValues: {
warehouse_ids: OptionType<number>[];
};
onSubmit: (values: { warehouse_ids: OptionType<number>[] }) => void;
onReset: () => void;
}
const StockLogFilterModal = ({
ref,
productWarehouses,
initialValues,
onSubmit,
onReset,
}: StockLogFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
const warehouseOptions: OptionType<number>[] = productWarehouses.map(
(pw) => ({
label: pw.warehouse_name,
value: pw.warehouse_id,
})
);
const formik = useFormik({
initialValues,
enableReinitialize: true,
onSubmit: (values) => {
onSubmit(values);
closeModalHandler();
},
});
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({ values: { warehouse_ids: [] } });
onReset();
closeModalHandler();
}, [resetForm, onReset]);
return (
<Modal ref={ref} className={{ modalBox: 'p-0 rounded-xl' }}>
<form
onSubmit={formik.handleSubmit}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
<div className='p-4 flex items-center justify-between gap-2 border-b border-gray-300'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Stock Log</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputCheckbox
label='Gudang'
isClearable
placeholder='Pilih gudang'
options={warehouseOptions}
value={formik.values.warehouse_ids}
onChange={(val) =>
formik.setFieldValue('warehouse_ids', val as OptionType<number>[])
}
isMulti
/>
</div>
<div className='p-4 flex justify-between gap-4 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='ghost'
color='none'
className='p-3 rounded-lg text-base-content/65'
>
Reset Filter
</Button>
<Button
type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default StockLogFilterModal;
@@ -1,95 +1,183 @@
import Button from '@/components/Button';
import Card from '@/components/Card';
import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLog } from '@/types/api/inventory/product';
import { StockLogApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock, StockLog } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
import { FileDown } from 'lucide-react';
import toast from 'react-hot-toast';
import { useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
const stockLogTableColumns: (warehouseName: string) => ColumnDef<StockLog>[] = (
warehouseName
) => [
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Gudang',
accessorKey: 'warehouse_name',
cell: warehouseName,
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
];
const StockLogTable = ({
stockLogs,
productWarehouse,
}: {
stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[];
productWarehouse: ProductWarehouseStock;
}) => {
const [isExportLoading, setIsExportLoading] = useState(false);
const [hasBeenVisible, setHasBeenVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHasBeenVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1 }
);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const {
state: tableFilterState,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
product_warehouse_id: productWarehouse.id,
},
});
const handleExportExcel = async () => {
setIsExportLoading(true);
try {
await StockLogApi.exportToExcel(
productWarehouse.warehouse_name,
getTableFilterQueryString()
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExportLoading(false);
}
};
const { data: stockLogsResponse, isLoading: isLoadingStockLogs } = useSWR(
hasBeenVisible
? `${StockLogApi.basePath}${getTableFilterQueryString()}`
: null,
StockLogApi.getAllFetcher
);
const stockLogs = isResponseSuccess(stockLogsResponse)
? stockLogsResponse.data
: [];
return (
<Card
title='Informasi Stock Produk'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<StockLog>
data={stockLogs}
columns={[
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
]}
<div ref={containerRef}>
<Card
title={`Informasi Stock Produk - ${productWarehouse.warehouse_name}`}
collapsible
variant='bordered'
className={{
containerClassName: 'mt-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
wrapper: 'w-full',
}}
/>
</Card>
>
<div className='flex justify-end px-6 pt-4'>
<Button onClick={handleExportExcel} isLoading={isExportLoading}>
<FileDown size={16} />
Export Excel
</Button>
</div>
<Table<StockLog>
data={stockLogs}
columns={stockLogTableColumns(productWarehouse.warehouse_name)}
page={tableFilterState.page ?? 0}
pageSize={tableFilterState.pageSize}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoadingStockLogs}
totalItems={
isResponseSuccess(stockLogsResponse)
? stockLogsResponse.meta?.total_results
: 0
}
className={{
containerClassName: 'mt-4 mb-0',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</Card>
</div>
);
};
@@ -1,13 +1,42 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
const stockProductWarehouseTableColumns: ColumnDef<ProductWarehouseStock>[] = [
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
];
const StockProductWarehouseTable = ({
productWarehouseStock,
}: {
productWarehouseStock?: ProductWarehouseStock[];
}) => {
const { state: tableFilterState, setPage, setPageSize } = useTableFilter();
return (
<Card
title='Informasi Gudang'
@@ -19,32 +48,14 @@ const StockProductWarehouseTable = ({
>
<Table<ProductWarehouseStock>
data={productWarehouseStock ?? []}
columns={[
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
]}
columns={stockProductWarehouseTableColumns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page ?? 0}
totalItems={productWarehouseStock?.length ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className={{
containerClassName: 'mt-6',
containerClassName: 'mt-6 mb-0',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
@@ -849,7 +849,11 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected}
>
Approve
{marketing?.data?.latest_approval?.step_number === 1 &&
'Approve'}
{marketing?.data?.latest_approval?.step_number === 2 &&
'Deliver Item'}
</Button>
</div>
)}
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useMemo } from 'react';
import { RefObject, useCallback, useMemo } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
@@ -17,20 +17,31 @@ import {
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi } from '@/services/api/master-data';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Product } from '@/types/api/master-data/product';
interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: MarketingFilter) => void;
onReset?: () => void;
initialValues?: {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
}
const MarketingFilterModal = ({
ref,
onSubmit,
onReset,
initialValues,
}: MarketingFilterModal) => {
const closeModalHandler = () => {
ref.current?.close();
@@ -38,36 +49,13 @@ const MarketingFilterModal = ({
// ===== OPTIONS =====
const {
rawData: productsRawData,
options: productsOptions,
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect<BaseMarketing>(
MarketingApi.basePath,
'id',
'so_number',
'search'
);
const productsOptions = useMemo(() => {
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
const productsMap = new Map<number, { value: number; label: string }>();
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
const product = so.product_warehouse?.product;
if (product?.id && product?.name) {
productsMap.set(product.id, {
value: product.id,
label: product.name,
});
}
});
});
return Array.from(productsMap.values());
}, [productsRawData]);
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
include_all: 'true',
});
const {
options: customersOptions,
@@ -78,6 +66,19 @@ const MarketingFilterModal = ({
has_marketing: 'true',
});
const {
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
setInputValue: setProjectFlockInputValue,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search'
);
const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
@@ -87,18 +88,29 @@ const MarketingFilterModal = ({
];
const formik = useFormik<MarketingFilterFormValues>({
initialValues: {
initialValues: initialValues || {
product_ids: [],
status: null,
customer: null,
project_flock: null,
project_flock_kandang: null,
},
validationSchema: MarketingFilterSchema,
onSubmit: async (values) => {
const formattedValues: MarketingFilter = {
product_ids: values.product_ids.map((item) => Number(item.value)),
product_names: values.product_ids.map((item) => item.label),
status: values.status?.value.toString() || '',
status_name: values.status?.label || '-',
customer_id: Number(values.customer?.value),
customer_name: values.customer?.label || '-',
project_flock_id: values.project_flock?.value || undefined,
project_flock_name: values.project_flock?.label,
project_flock_kandang_id:
Number(values.project_flock_kandang?.value) || undefined,
project_flock_kandang_name:
values.project_flock_kandang?.label || undefined,
};
onSubmit?.(formattedValues);
@@ -111,6 +123,22 @@ const MarketingFilterModal = ({
},
});
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({
values: {
product_ids: [],
status: null,
customer: null,
project_flock: null,
project_flock_kandang: null,
},
});
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]);
};
@@ -126,6 +154,27 @@ const MarketingFilterModal = ({
formik.setFieldValue('status', val as OptionType);
};
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
return (
<Modal
ref={ref}
@@ -135,7 +184,7 @@ const MarketingFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -192,6 +241,37 @@ const MarketingFilterModal = ({
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/>
<SelectInput
label='Project Flock'
isClearable
placeholder='Pilih Project Flock'
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
value={formik.values.project_flock}
onChange={(val) => {
formik.setFieldValue(
'project_flock',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
formik.setFieldValue('project_flock_kandang', null);
}}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
/>
<SelectInput
label='Kandang'
isClearable
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
)
}
isDisabled={!formik.values.project_flock}
/>
</div>
{/* Modal Footer */}
+589 -71
View File
@@ -2,26 +2,39 @@
import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
getErrorMessage,
isResponseError,
isResponseSuccess,
} from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import {
BaseSalesOrder,
Marketing,
MarketingFilter,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
import {
CellContext,
ColumnDef,
Row,
SortingState,
Updater,
} from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission';
@@ -154,12 +167,21 @@ const MarketingTable = () => {
);
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [bulkDeliveryDate, setBulkDeliveryDate] = useState('');
const [bulkDeliveryNotes, setBulkDeliveryNotes] = useState('');
const [isSubmittingBulkDelivery, setIsSubmittingBulkDelivery] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const router = useRouter();
const deleteModal = useModal();
const confirmationModal = useModal();
const productsModal = useModal();
const deliveryModal = useModal();
const bulkDeliveryModal = useModal();
const exportProgressInputModal = useModal();
const filterModal = useModal();
const {
@@ -172,8 +194,17 @@ const MarketingTable = () => {
initial: {
search: '',
product_ids: '',
product_names: '',
status: '',
status_name: '',
customer_id: '',
customer_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
@@ -181,9 +212,43 @@ const MarketingTable = () => {
product_ids: 'product_ids',
status: 'status',
customer_id: 'customer_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
sort_by: 'sort_by',
order_by: 'sort_order',
},
excludeKeysFromUrl: [
'product_names',
'status_name',
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'marketing-table',
});
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// ===== FETCH DATA =====
const {
data: marketing,
@@ -198,26 +263,64 @@ const MarketingTable = () => {
const filterSubmitHandler = (values: MarketingFilter) => {
updateFilter(
'product_ids',
values.product_ids?.map((item) => item.toString()).join(',')
values.product_ids?.map((item) => item.toString()).join(','),
true
);
updateFilter('status', values.status ? values.status.toString() : '');
updateFilter('product_names', values.product_names?.join(','));
updateFilter('status', values.status ? values.status.toString() : '', true);
updateFilter('status_name', values.status_name, true);
updateFilter(
'customer_id',
values.customer_id ? values.customer_id.toString() : ''
values.customer_id ? values.customer_id.toString() : '',
true
);
updateFilter('customer_name', values.customer_name, true);
updateFilter(
'project_flock_id',
values.project_flock_id ? values.project_flock_id.toString() : '',
true
);
updateFilter('project_flock_name', values.project_flock_name ?? '', true);
updateFilter(
'project_flock_kandang_id',
values.project_flock_kandang_id
? values.project_flock_kandang_id.toString()
: '',
true
);
updateFilter(
'project_flock_kandang_name',
values.project_flock_kandang_name ?? '',
true
);
};
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeliveryLoading, setIsDeliveryLoading] = useState(false);
const filterResetHandler = () => {
updateFilter('product_ids', '');
updateFilter('status', '');
updateFilter('customer_id', '');
updateFilter('product_ids', '', true);
updateFilter('product_names', '', true);
updateFilter('status', '', true);
updateFilter('status_name', '', true);
updateFilter('customer_id', '', true);
updateFilter('customer_name', '', true);
updateFilter('project_flock_id', '', true);
updateFilter('project_flock_name', '', true);
updateFilter('project_flock_kandang_id', '', true);
updateFilter('project_flock_kandang_name', '', true);
};
const approveClickHandler = () => {
setApproveAction('APPROVED');
if (selectedApprovalStep === 2) {
bulkDeliveryModal.openModal();
return;
}
confirmationModal.openModal();
};
@@ -226,10 +329,13 @@ const MarketingTable = () => {
confirmationModal.openModal();
};
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
const productsClickHandler = useCallback(
(item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
},
[productsModal]
);
const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete(
@@ -251,75 +357,226 @@ const MarketingTable = () => {
const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()]
);
const selectedApprovalStep =
selectedRowsData.length > 0
? selectedRowsData[0].latest_approval.step_number
: null;
const hasApprovable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const hasRejectable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const eligibleSelectedRows = selectedRowsData.filter((row) => {
const approval = row.latest_approval;
if (approval.action === 'REJECTED') {
return false;
}
if (selectedApprovalStep === null) {
return approval.step_number === 1 || approval.step_number === 2;
}
return approval.step_number === selectedApprovalStep;
});
const hasApprovable = eligibleSelectedRows.length > 0;
const hasRejectable = eligibleSelectedRows.length > 0;
const disableApprove = !hasApprovable;
const disableReject = !hasRejectable;
const idsToProcess =
approveAction === 'APPROVED'
? selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id)
: selectedRowsData
.filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
const idsToProcess = eligibleSelectedRows.map((row) => row.id);
const nextApprovalStatus =
selectedApprovalStep === 1
? 'SALES_ORDER'
: selectedApprovalStep === 2
? 'DELIVERY_ORDER'
: null;
const productIds = tableFilterState.product_ids
? tableFilterState.product_ids
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const productLabels = tableFilterState.product_names
? tableFilterState.product_names
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const marketingFilterInitialValues = {
product_ids: productIds.map((value, idx) => ({
value: Number(value),
label: productLabels[idx] || '-',
})),
status: tableFilterState.status
? {
value: tableFilterState.status,
label: tableFilterState.status_name,
}
: null,
customer: tableFilterState.customer_id
? {
value: Number(tableFilterState.customer_id),
label: tableFilterState.customer_name,
}
: null,
project_flock: tableFilterState.project_flock_id
? {
value: Number(tableFilterState.project_flock_id),
label: tableFilterState.project_flock_name,
}
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? {
value: Number(tableFilterState.project_flock_kandang_id),
label: tableFilterState.project_flock_kandang_name,
}
: null,
};
const approveMarketingHandler = async (notes: string) => {
let idsToProcess: number[] = [];
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal();
return;
}
const approveMarketingRes = await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (approveAction === 'APPROVED' && selectedApprovalStep !== 1) {
toast.error('Approve tahap ini harus menggunakan tanggal pengiriman.');
confirmationModal.closeModal();
return;
}
if (isResponseSuccess(approveMarketingRes)) {
if (approveAction === 'APPROVED' && !nextApprovalStatus) {
toast.error('Status approval berikutnya tidak valid.');
confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string);
return;
}
setIsApproveLoading(true);
try {
const approveMarketingRes: BaseApiResponse<unknown> | undefined =
approveAction === 'APPROVED'
? await MarketingApi.bulkApprovals(
idsToProcess,
nextApprovalStatus as 'SALES_ORDER' | 'DELIVERY_ORDER',
'',
notes || `APPROVED marketing ${idsToProcess.join(', ')}`
)
: await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string);
setRowSelection({});
}
refreshMarketing();
} finally {
setIsApproveLoading(false);
}
};
const bulkDeliveryDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkDeliveryDate(e.target.value);
};
const bulkDeliveryNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkDeliveryNotes(e.target.value);
};
const submitBulkDeliveryApprovalHandler = async (
selectedIds: number[],
deliveryDate: string,
notes: string
) => {
if (selectedIds.length === 0) {
toast.error('Tidak ada data yang valid untuk diproses.');
return;
}
if (!deliveryDate) {
toast.error('Tanggal pengiriman wajib diisi.');
return;
}
setIsSubmittingBulkDelivery(true);
try {
const bulkDeliveryApprovalRes = await MarketingApi.bulkApprovals(
selectedIds,
'DELIVERY_ORDER',
deliveryDate,
notes || `APPROVED delivery marketing ${selectedIds.join(', ')}`
);
if (isResponseError(bulkDeliveryApprovalRes)) {
toast.error(bulkDeliveryApprovalRes?.message as string);
return;
}
if (!isResponseSuccess(bulkDeliveryApprovalRes)) {
toast.error('Gagal memproses bulk approve delivery.');
return;
}
toast.success(bulkDeliveryApprovalRes?.message as string);
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
setRowSelection({});
refreshMarketing();
} finally {
setIsSubmittingBulkDelivery(false);
}
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
};
const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
deliveryModal.closeModal();
toast.success(res?.message as string);
refreshMarketing?.();
router.push(
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
);
setIsDeliveryLoading(true);
try {
const res = await SalesOrderApi.delivery(
selectedItem?.id as number,
notes
);
deliveryModal.closeModal();
toast.success(res?.message as string);
refreshMarketing?.();
router.push(
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
);
} finally {
setIsDeliveryLoading(false);
}
};
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
};
const getRowCanSelect = useCallback(
(row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
const isSelectableStep =
approval?.step_number === 1 || approval?.step_number === 2;
if (!isSelectableStep || approval?.action === 'REJECTED') {
return false;
}
if (selectedApprovalStep === null) {
return true;
}
return approval?.step_number === selectedApprovalStep;
},
[selectedApprovalStep]
);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
@@ -329,6 +586,53 @@ const MarketingTable = () => {
setIsLoadingExportingToExcel(false);
};
const resetExportProgressForm = () => {
setExportProgressStartDate('');
setExportProgressEndDate('');
};
const exportProgressStartDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressStartDate(e.target.value);
};
const exportProgressEndDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressEndDate(e.target.value);
};
const exportProgressInputToExcelClickHandler = () => {
resetExportProgressForm();
exportProgressInputModal.openModal();
};
const submitExportProgressInputHandler = async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await MarketingApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
};
const columns = useMemo<ColumnDef<Marketing>[]>(() => {
return [
{
@@ -336,7 +640,22 @@ const MarketingTable = () => {
size: 1,
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(getRowCanSelect);
const stepForBulkSelection =
selectedApprovalStep ??
allRows.find(getRowCanSelect)?.original.latest_approval.step_number;
const selectableRows = allRows.filter((row) => {
if (!getRowCanSelect(row)) {
return false;
}
if (!stepForBulkSelection) {
return false;
}
return (
row.original.latest_approval.step_number === stepForBulkSelection
);
});
const allSelected =
selectableRows.length > 0 &&
@@ -378,7 +697,7 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'so_do_number',
accessorKey: 'so_number',
header: 'No. Order',
cell: (props) => {
return props.row.original.do_number
@@ -394,7 +713,7 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'approval.step_name',
accessorKey: 'status',
header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
@@ -429,10 +748,12 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'customer.name',
accessorKey: 'customer',
header: 'Customer',
cell: (props) => props.row.original.customer.name,
},
{
accessorKey: 'grand_total',
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
@@ -449,6 +770,7 @@ const MarketingTable = () => {
{
accessorKey: 'marketing_products.length',
header: 'Product Details',
enableSorting: false,
cell: (props) => {
if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) {
@@ -470,6 +792,14 @@ const MarketingTable = () => {
}
},
},
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM yyyy')
: '-',
},
{
id: 'actions',
maxSize: 80,
@@ -504,7 +834,13 @@ const MarketingTable = () => {
},
},
];
}, []);
}, [
deleteModal,
deliveryModal,
getRowCanSelect,
productsClickHandler,
selectedApprovalStep,
]);
return (
<>
@@ -527,7 +863,7 @@ const MarketingTable = () => {
</RequirePermission>
{idsToProcess.length > 0 && (
<>
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px'></div>
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px' />
<RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button
color='error'
@@ -541,7 +877,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Reject
Reject ({idsToProcess.length} Item)
</Button>
</RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
@@ -557,7 +893,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Approve
Approve ({idsToProcess.length} Item)
</Button>
</RequirePermission>
</>
@@ -566,7 +902,18 @@ const MarketingTable = () => {
<div className='flex flex-row gap-3'>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
excludeFields={[
'page',
'pageSize',
'search',
'product_names',
'status_name',
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'sort_by',
'order_by',
]}
onClick={() => {
filterModal.openModal();
}}
@@ -612,7 +959,17 @@ const MarketingTable = () => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
Ekspor ke Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
@@ -646,6 +1003,9 @@ const MarketingTable = () => {
columns={columns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
totalItems={
isResponseSuccess(marketing)
? marketing?.meta?.total_results
@@ -677,14 +1037,16 @@ const MarketingTable = () => {
<ConfirmationModalWithNotes
ref={confirmationModal.ref}
type={approveAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan (${idsToProcess.length} data)?`}
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan tahap ${selectedApprovalStep ?? '-'} (${idsToProcess.length} data)?`}
secondaryButton={{
text: 'Tidak',
isLoading: isApproveLoading,
onClick: confirmationModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: approveAction === 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: approveMarketingHandler,
}}
/>
@@ -708,14 +1070,169 @@ const MarketingTable = () => {
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
secondaryButton={{
text: 'Tidak',
isLoading: isDeliveryLoading,
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isDeliveryLoading,
onClick: confirmationModalDeliveryClickHandler,
}}
/>
<Modal
ref={bulkDeliveryModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Bulk Approve Delivery
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<p className='text-sm text-base-content/70'>
Pilih tanggal pengiriman untuk approve {idsToProcess.length} data
penjualan tahap 2.
</p>
<DateInput
name='bulk_delivery_date'
label='Tanggal Pengiriman'
value={bulkDeliveryDate}
onChange={bulkDeliveryDateChangeHandler}
isNestedModal
required
/>
<TextArea
name='bulk_delivery_notes'
label='Catatan'
placeholder='Masukkan catatan approval...'
value={bulkDeliveryNotes}
onChange={bulkDeliveryNotesChangeHandler}
rows={4}
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
disabled={isSubmittingBulkDelivery}
onClick={() => {
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
isLoading={isSubmittingBulkDelivery}
disabled={isSubmittingBulkDelivery}
onClick={() =>
submitBulkDeliveryApprovalHandler(
idsToProcess,
bulkDeliveryDate,
bulkDeliveryNotes
)
}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<Modal
ref={productsModal.ref}
className={{
@@ -777,6 +1294,7 @@ const MarketingTable = () => {
ref={filterModal.ref}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
initialValues={marketingFilterInitialValues}
/>
</>
);
@@ -246,6 +246,7 @@ const SalesOrderFormModal = ({
})
.filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload);
switch (modalAction) {
case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -261,11 +262,7 @@ const SalesOrderFormModal = ({
// ===== Formik Error List =====
const { formErrorList, setFormErrorList, close, handleFormSubmit } =
useFormikErrorList(formik, {
onAfterSubmit: () => {
router.push('/marketing');
},
});
useFormikErrorList(formik);
// ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
@@ -5,10 +5,14 @@ export const MarketingFilterSchema = object({
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
status: mixed<OptionType<string>>().nullable(),
customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: mixed<OptionType<number>>().nullable(),
});
export type MarketingFilterFormValues = {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
@@ -71,14 +71,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
.required('Pengiriman wajib diisi!')
.test(
'at-least-one-valid-row',
'Minimal harus ada satu baris pengiriman yang lengkap diisi!',
'Seluruh data pengiriman harus diisi lengkap!',
function (items) {
if (!items || items.length === 0) return false;
// VALIDASI: minimal 1 item valid full
// VALIDASI: seluruh item harus valid full
const itemSchema = DeliveryOrderProductSchema;
const hasValidItem = items.some((item) => {
const hasValidItem = items.every((item) => {
if (!item) return false;
return itemSchema.isValidSync(item, { abortEarly: true });
});
@@ -123,8 +123,17 @@ export const SalesProductToFieldValues = (
total_price: product.total_price,
marketing_type: product.marketing_type
? {
value: product.marketing_type,
label: formatTitleCase(product.marketing_type),
value:
product.marketing_type === 'AYAM' ||
product.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: product.marketing_type,
label: formatTitleCase(
product.marketing_type === 'AYAM' ||
product.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: product.marketing_type
),
}
: null,
convertion_unit: product.convertion_unit
@@ -144,9 +153,11 @@ export const DeliveryProductToFieldValues = (
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => {
const salesOrder = salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
);
const salesOrder =
salesOrders.find((so) => so.id === item.marketing_product_id) ??
salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
);
const warehouseOption = {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
@@ -180,11 +191,20 @@ export const DeliveryProductToFieldValues = (
vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number,
marketing_product_id: salesOrder?.id,
marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
marketing_type: salesOrder?.marketing_type
? {
value: salesOrder?.marketing_type,
label: formatTitleCase(salesOrder?.marketing_type),
value:
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: salesOrder?.marketing_type,
label: formatTitleCase(
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: salesOrder?.marketing_type
),
}
: null,
convertion_unit: salesOrder?.convertion_unit
@@ -194,7 +214,7 @@ export const DeliveryProductToFieldValues = (
}
: null,
marketing_product: {
id: salesOrder?.id,
id: item.marketing_product_id ?? salesOrder?.id,
vehicle_number: item.vehicle_number,
warehouse_id: item.product_warehouse.warehouse.id,
warehouse: warehouseOption,
@@ -146,15 +146,6 @@ const DeliveryOrderProductForm = ({
);
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
// Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => {
@@ -190,12 +181,19 @@ const DeliveryOrderProductForm = ({
const deliveryOrder = useMemo(() => {
if (!hasDeliveryOrder || !deliveryOrders) return null;
const marketingProductId =
initialValues?.marketing_product_id ?? initialValues?.id;
for (const doItem of deliveryOrders) {
const found = doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
const found =
doItem.deliveries.find(
(d) => d.marketing_product_id === marketingProductId
) ??
doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
if (found) {
return {
...found,
@@ -403,7 +401,10 @@ const DeliveryOrderProductForm = ({
useEffect(() => {
if (initialValues) {
if (!Boolean(initialValues.qty)) {
if (
!Boolean(initialValues.qty) &&
!Boolean(initialValues.marketing_product_id)
) {
handleResetForm();
} else {
setFormikValues({
@@ -413,7 +414,7 @@ const DeliveryOrderProductForm = ({
});
if (initialValues?.marketing_product_id) {
setSelectedProduct({
value: initialValues?.id,
value: initialValues?.marketing_product_id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
} as OptionType);
}
@@ -430,7 +431,8 @@ const DeliveryOrderProductForm = ({
handleBlurField(currentInput);
formik.setFieldValue(
'uom',
isResponseSuccess(productData) ? productData?.data?.uom?.name : ''
initialValues?.marketing_product?.product_warehouse_data?.product?.uom
?.name ?? ''
);
},
}
@@ -803,9 +805,8 @@ const DeliveryOrderProductForm = ({
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
{initialValues?.marketing_product?.product_warehouse_data
?.product?.uom?.name ?? ''}
</span>
</div>
}
@@ -816,9 +817,8 @@ const DeliveryOrderProductForm = ({
(item) => item.id === formik.values.marketing_product_id
)?.qty +
' ' +
(isResponseSuccess(productData)
? productData?.data?.uom.name
: '')
(initialValues?.marketing_product?.product_warehouse_data
?.product?.uom?.name ?? '')
: ''
}
/>
@@ -252,6 +252,11 @@ const SalesOrderProductForm = ({
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity);
if (productWarehouse?.quantity) {
handleFieldChange('qty', productWarehouse?.quantity);
}
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if (
productWarehouse?.week !== undefined &&
@@ -124,7 +124,7 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty
{item.qty !== undefined && item.qty !== null && item.qty !== ''
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
: '-'}
</td>
@@ -273,7 +273,7 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty
{item.qty !== undefined && item.qty !== null && item.qty !== ''
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
: '-'}
</td>
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const AreasTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const AreasTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'areas-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -137,17 +134,8 @@ const AreasTable = () => {
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('areas-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const BanksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const BanksTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'banks-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -137,17 +134,8 @@ const BanksTable = () => {
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('banks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const CustomersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const CustomersTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'customers-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -139,17 +136,8 @@ const CustomersTable = () => {
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('customers-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -201,6 +189,11 @@ const CustomersTable = () => {
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{
header: 'Aksi',
cell: (props: CellContext<Customer, unknown>) => {
@@ -27,6 +27,9 @@ export const CustomerFormSchema = Yup.object({
.email('Format email tidak valid!')
.required('Email wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'),
@@ -142,6 +142,7 @@ const CustomerForm = ({
},
type: normalizeType(initialValues?.type),
address: initialValues?.address ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '',
};
}, [initialValues]);
@@ -164,6 +165,7 @@ const CustomerForm = ({
pic_id: values.picId,
type: (values.type as OptionType).value as string,
address: values.address,
bank_name: values.bank_name,
account_number: values.account_number,
};
@@ -286,6 +288,22 @@ const CustomerForm = ({
errorMessage={formik.errors.phone}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank customer'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Flock } from '@/types/api/master-data/flock';
import { FlockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const FlockTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const FlockTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'flock-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -139,17 +136,8 @@ const FlockTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('flocks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -35,7 +28,6 @@ import { User } from '@/types/api/api-general';
import { formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
KandangFilterSchema,
KandangFilterType,
@@ -122,20 +114,21 @@ const RowOptionsMenu = ({
};
const KandangsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: {
search: '',
locationFilter: '',
picFilter: '',
locationFilter: undefined,
picFilter: undefined,
},
paramMap: {
page: 'page',
@@ -143,6 +136,8 @@ const KandangsTable = () => {
locationFilter: 'location_id',
picFilter: 'pic_id',
},
persist: true,
storeName: 'kandangs-table',
});
// ===== FILTER MODAL STATE =====
@@ -151,22 +146,34 @@ const KandangsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<KandangFilterType>({
initialValues: {
location_id: null,
pic_id: null,
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
},
validationSchema: KandangFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('locationFilter', values.location_id || '');
updateFilter('picFilter', values.pic_id || '');
updateFilter('locationFilter', values.location || undefined, true);
updateFilter('picFilter', values.pic || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('locationFilter', '');
updateFilter('picFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('locationFilter', undefined, true);
updateFilter('picFilter', undefined, true);
formik.resetForm({
values: {
location: undefined,
pic: undefined,
},
});
filterModal.closeModal();
};
const { setFieldValue } = formik;
// ===== LOCATION OPTIONS =====
const {
setInputValue: setLocationInputValue,
@@ -194,43 +201,15 @@ const KandangsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location?.value ? String(location.value) : null;
const handleFilterLocationChange = (
val: OptionType | OptionType[] | null
) => {
setFieldValue('location', val);
};
formik.setFieldValue('location_id', locationId);
},
[formik]
);
const handleFilterPicChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const pic = val as OptionType | null;
const picId = pic?.value ? String(pic.value) : null;
formik.setFieldValue('pic_id', picId);
},
[formik]
);
// ===== FILTER HELPERS =====
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const picIdValue = useMemo(() => {
if (!formik.values.pic_id) return null;
return (
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
null
);
}, [formik.values.pic_id, picOptions]);
const handleFilterPicChange = (val: OptionType | OptionType[] | null) => {
setFieldValue('pic', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -255,17 +234,8 @@ const KandangsTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('kandangs-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -475,13 +445,13 @@ const KandangsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationIdValue}
value={formik.values.location}
onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
@@ -494,7 +464,7 @@ const KandangsTable = () => {
label='PIC'
placeholder='Pilih PIC'
options={picOptions}
value={picIdValue}
value={formik.values.pic}
onChange={handleFilterPicChange}
onInputChange={setPicInputValue}
isLoading={isLoadingPicOptions}
@@ -510,17 +480,14 @@ const KandangsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
disabled={!formik.isValid}
>
Apply Filter
</Button>
@@ -1,11 +1,19 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const KandangFilterSchema = object().shape({
location_id: string().nullable(),
pic_id: string().nullable(),
export const KandangFilterSchema = Yup.object().shape({
location: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
pic: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type KandangFilterType = {
location_id: string | null;
pic_id: string | null;
location?: OptionType<string>;
pic?: OptionType<string>;
};
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -32,7 +25,6 @@ import { Area } from '@/types/api/master-data/area';
import { LocationApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
LocationFilterSchema,
LocationFilterType,
@@ -118,25 +110,27 @@ const RowOptionsMenu = ({
};
const LocationsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
areaFilter?: OptionType<string>;
}>({
initial: {
search: '',
areaFilter: '',
areaFilter: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
areaFilter: 'area_id',
},
persist: true,
storeName: 'locations-table',
});
// ===== FILTER MODAL STATE =====
@@ -145,19 +139,28 @@ const LocationsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<LocationFilterType>({
initialValues: {
area_id: null,
area: tableFilterState.areaFilter,
},
validationSchema: LocationFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '');
updateFilter('areaFilter', values.area || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('areaFilter', undefined, true);
formik.resetForm({
values: {
area: undefined,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS =====
const {
setInputValue: setAreaInputValue,
@@ -172,24 +175,9 @@ const LocationsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterAreaChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const area = val as OptionType | null;
const areaId = area?.value ? String(area.value) : null;
formik.setFieldValue('area_id', areaId);
},
[formik]
);
// ===== FILTER HELPERS =====
const areaIdValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('area', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -212,19 +200,10 @@ const LocationsTable = () => {
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('locations-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -425,13 +404,13 @@ const LocationsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={areaIdValue}
value={formik.values.area}
onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions}
@@ -447,10 +426,7 @@ const LocationsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
@@ -1,9 +1,13 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const LocationFilterSchema = object().shape({
area_id: string().nullable(),
export const LocationFilterSchema = Yup.object().shape({
area: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type LocationFilterType = {
area_id: string | null;
area?: OptionType<string>;
};
@@ -20,8 +20,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const NonstocksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,22 +109,16 @@ const NonstocksTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'nonstock-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('nonstocks-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -148,8 +137,7 @@ const NonstocksTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,7 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -21,7 +20,6 @@ import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const ProductCategoryTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -120,12 +115,10 @@ const ProductCategoryTable = () => {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'product-category-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -144,8 +137,7 @@ const ProductCategoryTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -214,10 +206,6 @@ const ProductCategoryTable = () => {
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
useEffect(() => {
setTableState('product-category-table', pathname);
}, [pathname, setTableState]);
return (
<>
<div className='w-full'>
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -33,7 +26,6 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
import { formatCurrency } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
ProductFilterSchema,
ProductFilterType,
@@ -119,25 +111,27 @@ const RowOptionsMenu = ({
};
const ProductsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
productCategoryFilter?: OptionType<string>;
}>({
initial: {
search: '',
productCategoryFilter: '',
productCategoryFilter: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
productCategoryFilter: 'product_category_id',
},
persist: true,
storeName: 'product-table',
});
// ===== FILTER MODAL STATE =====
@@ -146,19 +140,32 @@ const ProductsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<ProductFilterType>({
initialValues: {
product_category_id: null,
product_category: tableFilterState.productCategoryFilter,
},
validationSchema: ProductFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productCategoryFilter', values.product_category_id || '');
updateFilter(
'productCategoryFilter',
values.product_category || undefined,
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productCategoryFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('productCategoryFilter', undefined, true);
formik.resetForm({
values: {
product_category: undefined,
},
});
filterModal.closeModal();
};
// ===== PRODUCT CATEGORY OPTIONS =====
const {
setInputValue: setProductCategoryInputValue,
@@ -173,25 +180,11 @@ const ProductsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterProductCategoryChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const category = val as OptionType | null;
const categoryId = category?.value ? String(category.value) : null;
formik.setFieldValue('product_category_id', categoryId);
},
[formik]
);
// ===== FILTER HELPERS =====
const productCategoryIdValue = useMemo(() => {
if (!formik.values.product_category_id) return null;
return (
productCategoryOptions.find(
(opt) => String(opt.value) === formik.values.product_category_id
) || null
);
}, [formik.values.product_category_id, productCategoryOptions]);
const handleFilterProductCategoryChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('product_category', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -199,10 +192,6 @@ const ProductsTable = () => {
formik.validateForm();
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -220,13 +209,8 @@ const ProductsTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setTableState('product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -477,13 +461,13 @@ const ProductsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Kategori Produk'
placeholder='Pilih Kategori Produk'
options={productCategoryOptions}
value={productCategoryIdValue}
value={formik.values.product_category}
onChange={handleFilterProductCategoryChange}
onInputChange={setProductCategoryInputValue}
isLoading={isLoadingProductCategoryOptions}
@@ -499,10 +483,7 @@ const ProductsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
@@ -1,9 +1,13 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const ProductFilterSchema = object().shape({
product_category_id: string().nullable(),
export const ProductFilterSchema = Yup.object().shape({
product_category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type ProductFilterType = {
product_category_id: string | null;
product_category?: OptionType<string>;
};
@@ -128,27 +128,44 @@ const ProductionStandardTable = () => {
pageSize: 'limit',
projectCategoryFilter: 'project_category',
},
persist: true,
storeName: 'production-standard-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
const filterInitialValues = useMemo<ProductionStandardFilterType>(
() => ({
project_category: tableFilterState.projectCategoryFilter || null,
}),
[tableFilterState.projectCategoryFilter]
);
// ===== FORMIK SETUP =====
const formik = useFormik<ProductionStandardFilterType>({
initialValues: {
project_category: null,
},
initialValues: filterInitialValues,
validationSchema: ProductionStandardFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('projectCategoryFilter', values.project_category || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('projectCategoryFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('projectCategoryFilter', '', true);
formik.resetForm({
values: {
project_category: null,
},
});
filterModal.closeModal();
};
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
const projectCategoryOptions = useMemo(
() => [
@@ -381,7 +398,7 @@ const ProductionStandardTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio
label='Kategori'
@@ -397,13 +414,9 @@ const ProductionStandardTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -7,7 +7,6 @@ import {
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -30,7 +29,7 @@ import { Supplier } from '@/types/api/master-data/supplier';
import { SupplierApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
SupplierFilterSchema,
SupplierFilterType,
@@ -117,20 +116,21 @@ const RowOptionsMenu = ({
};
const SuppliersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
flagFilter?: string;
}>({
initial: {
search: '',
categoryFilter: '',
flagFilter: '',
categoryFilter: undefined,
flagFilter: undefined,
},
paramMap: {
page: 'page',
@@ -138,6 +138,8 @@ const SuppliersTable = () => {
categoryFilter: 'category_id',
flagFilter: 'flag',
},
persist: true,
storeName: 'supplier-table',
});
// ===== FILTER MODAL STATE =====
@@ -146,26 +148,33 @@ const SuppliersTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<SupplierFilterType>({
initialValues: {
category_id: null,
flag: false,
category: tableFilterState.categoryFilter,
flag: tableFilterState.flagFilter === 'EKSPEDISI',
},
validationSchema: SupplierFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category_id || '');
updateFilter(
'flagFilter',
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
);
updateFilter('categoryFilter', values.category || undefined, true);
updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('categoryFilter', '');
updateFilter('flagFilter', '');
formik.setFieldValue('flag', false);
},
});
const formikResetHandler = () => {
updateFilter('categoryFilter', undefined, true);
updateFilter('flagFilter', '', true);
formik.resetForm({
values: {
category: undefined,
flag: false,
},
});
filterModal.closeModal();
};
const { setFieldValue } = formik;
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
@@ -187,15 +196,11 @@ const SuppliersTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterCategoryChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType | null;
const categoryId = option?.value ? String(option.value) : null;
setFieldValue('category_id', categoryId);
},
[setFieldValue]
);
const handleFilterCategoryChange = (
val: OptionType | OptionType[] | null
) => {
setFieldValue('category', val);
};
const handleFilterFlagChange = useCallback(
(val: OptionType | OptionType[] | null) => {
@@ -213,13 +218,13 @@ const SuppliersTable = () => {
);
// ===== FILTER HELPERS =====
const categoryIdValue = useMemo(() => {
if (!formik.values.category_id) return null;
return (
categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
null
);
}, [formik.values.category_id, categoryOptions]);
// const categoryIdValue = useMemo(() => {
// if (!formik.values.category_id) return null;
// return (
// categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
// null
// );
// }, [formik.values.category_id, categoryOptions]);
const flagValue = useMemo(() => {
if (formik.values.flag === null) return null;
@@ -243,14 +248,6 @@ const SuppliersTable = () => {
}
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('suppliers-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -269,8 +266,7 @@ const SuppliersTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -330,6 +326,11 @@ const SuppliersTable = () => {
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{
accessorKey: 'address',
header: 'Alamat',
@@ -491,13 +492,13 @@ const SuppliersTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={categoryIdValue}
value={formik.values.category}
onChange={handleFilterCategoryChange}
isClearable
className={{ wrapper: 'w-full' }}
@@ -517,13 +518,9 @@ const SuppliersTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,11 +1,16 @@
import { string, boolean, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const SupplierFilterSchema = object().shape({
category_id: string().nullable(),
flag: boolean().nullable(),
export const SupplierFilterSchema = Yup.object().shape({
category: Yup.object({
value: Yup.string().required(),
label: Yup.string().required(),
}).nullable(),
flag: Yup.boolean().nullable(),
});
export type SupplierFilterType = {
category_id: string | null;
category?: OptionType<string>;
flag: boolean | null;
};
@@ -31,6 +31,9 @@ export const SupplierFormSchema = Yup.object({
npwp: Yup.string()
.matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
.required('Nomor NPWP wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'),
@@ -122,6 +122,7 @@ const SupplierForm = ({
email: initialValues?.email ?? '',
address: initialValues?.address ?? '',
npwp: initialValues?.npwp ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '',
due_date: initialValues?.due_date ?? 1,
};
@@ -149,6 +150,7 @@ const SupplierForm = ({
email: values.email,
address: values.address,
npwp: values.npwp,
bank_name: values.bank_name,
account_number: values.account_number,
due_date: parseInt(values.due_date.toString()),
};
@@ -368,6 +370,22 @@ const SupplierForm = ({
errorMessage={formik.errors.npwp}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank supplier'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const UomsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,22 +109,16 @@ const UomsTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'uom-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('uoms-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -146,8 +135,7 @@ const UomsTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -31,7 +24,6 @@ import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
WarehouseFilterSchema,
WarehouseFilterType,
@@ -120,9 +112,6 @@ const RowOptionsMenu = ({
};
const WarehousesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -141,6 +130,8 @@ const WarehousesTable = () => {
areaFilter: 'area_id',
activeProjectFlockFilter: 'active_project_flock',
},
persist: true,
storeName: 'warehouses-table',
});
// ===== FILTER MODAL STATE =====
@@ -149,27 +140,36 @@ const WarehousesTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<WarehouseFilterType>({
initialValues: {
area_id: null,
active_project_flock: false,
area_id: tableFilterState.areaFilter || null,
active_project_flock:
tableFilterState.activeProjectFlockFilter === 'true',
},
validationSchema: WarehouseFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '');
updateFilter('areaFilter', values.area_id || '', true);
updateFilter(
'activeProjectFlockFilter',
values.active_project_flock === true ? 'true' : ''
values.active_project_flock === true ? 'true' : '',
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('activeProjectFlockFilter', '');
formik.setFieldValue('active_project_flock', false);
},
});
const { setFieldValue } = formik;
const formikResetHandler = () => {
updateFilter('areaFilter', '', true);
updateFilter('activeProjectFlockFilter', '', true);
formik.resetForm({
values: {
area_id: null,
active_project_flock: false,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS =====
const {
@@ -243,26 +243,6 @@ const WarehousesTable = () => {
formik.validateForm();
};
useEffect(() => {
if (filterModal.open) {
const activeProjectFlockValue =
tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang)
setFieldValue('active_project_flock', activeProjectFlockValue);
}
}, [
filterModal.open,
tableFilterState.activeProjectFlockFilter,
setFieldValue,
]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('warehouses-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -281,8 +261,7 @@ const WarehousesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -507,7 +486,7 @@ const WarehousesTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
@@ -538,10 +517,7 @@ const WarehousesTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
@@ -11,7 +11,6 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import Dropdown from '@/components/Dropdown';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
@@ -23,7 +22,6 @@ import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useRouter, usePathname } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { useUiStore } from '@/stores/ui/ui.store';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import { useFormik } from 'formik';
@@ -45,6 +43,7 @@ import {
import Modal from '@/components/Modal';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import ButtonFilter from '@/components/helper/ButtonFilter';
import NumberInput from '@/components/input/NumberInput';
const RowOptionsMenu = ({
props,
@@ -148,7 +147,6 @@ const RowOptionsMenu = ({
};
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
@@ -174,6 +172,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
kandang_id: '',
category: '',
period: '',
area_name: '',
location_name: '',
kandang_name: '',
},
paramMap: {
page: 'page',
@@ -185,7 +186,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
category: 'category',
period: 'period',
},
excludeKeysFromUrl: ['area_name', 'location_name', 'kandang_name'],
persist: true,
storeName: 'project-flock-table',
});
const router = useRouter();
// ===== State =====
@@ -206,8 +211,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const {
isChickinApproveModalOpen,
isChickinApproveLoading,
@@ -257,6 +261,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', values.kandang_id || '');
updateFilter('category', values.category || '');
updateFilter('period', values.period || '');
updateFilter(
'area_name',
areaValue?.label ? String(areaValue.label) : ''
);
updateFilter(
'location_name',
locationValue?.label ? String(locationValue.label) : ''
);
updateFilter(
'kandang_name',
kandangValue?.label ? String(kandangValue.label) : ''
);
filterModal.closeModal();
setSubmitting(false);
},
@@ -266,6 +282,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', '');
updateFilter('category', '');
updateFilter('period', '');
updateFilter('area_name', '');
updateFilter('location_name', '');
updateFilter('kandang_name', '');
setFilterAreaId(undefined);
setFilterLocationId(undefined);
filterModal.closeModal();
@@ -307,40 +326,55 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[]
);
const periodOptions = useMemo(
() => [
{ value: '1', label: 'Periode 1' },
{ value: '2', label: 'Periode 2' },
],
[]
);
// ===== FILTER HELPERS =====
const areaValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
const found = areaOptions.find(
(opt) => String(opt.value) === formik.values.area_id
);
}, [formik.values.area_id, areaOptions]);
if (found) return found;
if (tableFilterState.area_name) {
return {
value: formik.values.area_id,
label: tableFilterState.area_name,
};
}
return null;
}, [formik.values.area_id, areaOptions, tableFilterState.area_name]);
const locationValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
const found = locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
);
}, [formik.values.location_id, locationOptions]);
if (found) return found;
if (tableFilterState.location_name) {
return {
value: formik.values.location_id,
label: tableFilterState.location_name,
};
}
return null;
}, [
formik.values.location_id,
locationOptions,
tableFilterState.location_name,
]);
const kandangValue = useMemo(() => {
if (!formik.values.kandang_id) return null;
return (
kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
const found = kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
);
}, [formik.values.kandang_id, kandangOptions]);
if (found) return found;
if (tableFilterState.kandang_name) {
return {
value: formik.values.kandang_id,
label: tableFilterState.kandang_name,
};
}
return null;
}, [formik.values.kandang_id, kandangOptions, tableFilterState.kandang_name]);
const categoryValue = useMemo(() => {
if (!formik.values.category) return null;
@@ -350,13 +384,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
}, [formik.values.category, categoryOptions]);
const periodValue = useMemo(() => {
if (!formik.values.period) return null;
return (
periodOptions.find((opt) => opt.value === formik.values.period) || null
);
}, [formik.values.period, periodOptions]);
// ===== FILTER DEPENDENCY HANDLERS =====
const handleFilterAreaChange = (area: OptionType | null) => {
const areaId = area?.value ? String(area.value) : undefined;
@@ -425,18 +452,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setIsDeleteLoading(false);
setRowSelection({});
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('project-flock-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmApprovalHandler = async (
notes: string,
approvalAction: 'APPROVED' | 'REJECTED'
@@ -554,6 +574,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
price: budget.price,
total_price: budget.qty * budget.price,
})) || [],
periode: createdProjectFlock.period ?? '-',
} as ProjectFlockFormValues;
}, [createdProjectFlock]);
@@ -776,14 +797,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[]
);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
toast.error('Not implemented yet!');
setIsLoadingExportingToExcel(false);
};
const bulkApproveClickHandler = () => {
setApprovalAction('APPROVED');
confirmModal.openModal();
@@ -972,55 +985,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
excludeFields={[
'page',
'pageSize',
'search',
'area_name',
'location_name',
'kandang_name',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcelHandler}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
</Dropdown>
</div>
</div>
@@ -1349,18 +1324,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
isClearable={true}
/>
<SelectInputRadio
<NumberInput
label='Periode'
placeholder='Pilih Periode'
options={periodOptions}
value={periodValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue('period', val?.value || null);
}
}}
name='period'
placeholder='Masukkan Periode'
value={formik.values.period ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={{ wrapper: 'w-full' }}
isClearable
/>
</div>
@@ -26,6 +26,7 @@ type ProjectFlockFormSchemaType = {
label: string;
} | null;
location_id: number;
periode: number | string;
kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[];
};
@@ -109,6 +110,12 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'),
// Period
periode: Yup.number()
.typeError('Periode harus berupa angka!')
.min(1, 'Periode minimal 1!')
.required('Periode wajib diisi!'),
kandang_ids: Yup.array()
.of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!')
@@ -152,6 +152,10 @@ export const ProjectFlockFormConfirmationTable = ({
label: 'Standar Produksi',
value: projectFlockForm?.production_standard?.label ?? '-',
},
{
label: 'Periode',
value: projectFlockForm?.periode ?? '-',
},
{
label: 'Informasi Kandang',
value: '',
@@ -261,7 +265,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingFlocks,
options: optionsFlock,
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
} = useSelect(FlockApi.basePath, 'id', 'name', 'search', {
project_category: selectedCategory,
location_id: selectedLocation,
area_id: selectedArea,
@@ -279,7 +283,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingLocations,
setInputValue: setInputValueLocation,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
area_id:
selectedArea != ''
? selectedArea
@@ -291,7 +295,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', {
project_category: selectedCategory,
});
@@ -307,7 +311,7 @@ const ProjectFlockForm = ({
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
`${selectedFlock?.toString()}/periods`,
selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined,
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
);
@@ -529,6 +533,7 @@ const ProjectFlockForm = ({
kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id
) as number[],
periode: initialValues?.period ?? '',
project_budgets: initialValues?.project_budgets?.map((budget) => {
return {
nonstock: {
@@ -568,6 +573,7 @@ const ProjectFlockForm = ({
category: values.category as string,
production_standard_id: values.production_standard_id as number,
location_id: values.location_id as number,
periode: parseInt(values.periode as unknown as string),
kandang_ids: values.kandang_ids as number[],
project_budgets: values.project_budgets.flatMap((budget) => {
return {
@@ -793,6 +799,7 @@ const ProjectFlockForm = ({
formik.values.kandang_ids?.includes(kandang.id)
)?.period
: undefined;
const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
@@ -1022,12 +1029,18 @@ const ProjectFlockForm = ({
isDisabled={formType != 'add'}
/>
<NumberInput
name='period'
name='periode'
label='Periode'
disabled
readOnly
placeholder='Periode Flock'
value={selectedLocation ? inputPeriod : ''}
value={formik.values.periode}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
allowNegative={false}
decimalScale={0}
isError={
formik.touched.periode && Boolean(formik.errors.periode)
}
errorMessage={formik.errors.periode as string}
/>
</div>
@@ -1,12 +1,6 @@
'use client';
import React, {
useCallback,
useState,
useMemo,
useEffect,
useRef,
} from 'react';
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
@@ -18,6 +12,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { OptionType } from '@/components/input/SelectInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -39,13 +34,11 @@ import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast';
import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import { useUiStore } from '@/stores/ui/ui.store';
import { usePathname } from 'next/navigation';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
@@ -75,6 +68,26 @@ const getStatusBadgeColor = (status: string): Color => {
return statusBadgeColorMap[normalizedStatus] || 'neutral';
};
const isRecordingApproved = (recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
};
// ===== FILTER HELPERS =====
const recordingApprovalStatusOptions: OptionType<string>[] = [
{ value: 'CREATED', label: 'Pengajuan' },
{ value: 'UPDATED', label: 'Diperbarui' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const projectFlockCategoryOptions: OptionType<string>[] = [
{ value: 'GROWING', label: 'Growing' },
{ value: 'LAYING', label: 'Laying' },
];
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
@@ -266,80 +279,111 @@ const RowOptionsMenu = ({
};
const RecordingTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
areaFilter: OptionType<number> | null;
locationFilter: OptionType<number> | null;
projectFlockFilter: OptionType<number> | null;
kandangFilter: OptionType<number> | null;
projectFlockKandangFilter: number | null;
approvalStatusFilter: OptionType<string> | null;
projectFlockCategoryFilter: OptionType<string> | null;
}>({
initial: {
search: '',
areaFilter: '',
locationFilter: '',
kandangFilter: '',
projectFlockKandangFilter: '',
areaFilter: null,
locationFilter: null,
projectFlockFilter: null,
kandangFilter: null,
projectFlockKandangFilter: null,
approvalStatusFilter: null,
projectFlockCategoryFilter: null,
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
areaFilter: 'area_id',
locationFilter: 'location_id',
projectFlockFilter: 'project_flock_id',
kandangFilter: 'kandang_id',
projectFlockKandangFilter: 'project_flock_kandang_id',
approvalStatusFilter: 'approval_status',
projectFlockCategoryFilter: 'project_flock_category',
},
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
persist: true,
storeName: 'recording-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FILTER STATE =====
const [filterArea, setFilterArea] = useState<OptionType | null>(null);
const [filterLocation, setFilterLocation] = useState<OptionType | null>(null);
const [filterProjectFlock, setFilterProjectFlock] =
useState<OptionType | null>(null);
const [filterKandang, setFilterKandang] = useState<OptionType | null>(null);
const [, setFilterProjectFlockKandangId] = useState<number | undefined>(
undefined
);
const [filterLocationAreaId, setFilterLocationAreaId] = useState<string>('');
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
// ===== FORMIK SETUP =====
const formik = useFormik<RecordingFilterType>({
initialValues: {
area_id: null,
location_id: null,
kandang_id: null,
project_flock_kandang_id: null,
area_id: tableFilterState.areaFilter,
location_id: tableFilterState.locationFilter,
project_flock_id: tableFilterState.projectFlockFilter,
kandang_id: tableFilterState.kandangFilter,
project_flock_kandang_id: tableFilterState.projectFlockKandangFilter,
approval_status: tableFilterState.approvalStatusFilter,
project_flock_category: tableFilterState.projectFlockCategoryFilter,
},
validationSchema: RecordingFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '');
updateFilter('locationFilter', values.location_id || '');
updateFilter('kandangFilter', values.kandang_id || '');
updateFilter('areaFilter', values.area_id, true);
updateFilter('locationFilter', values.location_id, true);
updateFilter('projectFlockFilter', values.project_flock_id, true);
updateFilter('kandangFilter', values.kandang_id, true);
updateFilter(
'projectFlockKandangFilter',
values.project_flock_kandang_id || ''
values.project_flock_kandang_id,
true
);
updateFilter('approvalStatusFilter', values.approval_status, true);
updateFilter(
'projectFlockCategoryFilter',
values.project_flock_category,
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('locationFilter', '');
updateFilter('kandangFilter', '');
updateFilter('projectFlockKandangFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('areaFilter', null, true);
updateFilter('locationFilter', null, true);
updateFilter('projectFlockFilter', null, true);
updateFilter('kandangFilter', null, true);
updateFilter('projectFlockKandangFilter', null, true);
updateFilter('approvalStatusFilter', null, true);
updateFilter('projectFlockCategoryFilter', null, true);
formik.resetForm({
values: {
area_id: null,
location_id: null,
project_flock_id: null,
kandang_id: null,
project_flock_kandang_id: null,
approval_status: null,
project_flock_category: null,
},
});
filterModal.closeModal();
};
const { project_flock_id, kandang_id } = formik.values;
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
@@ -355,10 +399,14 @@ const RecordingTable = () => {
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const singleDeleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const exportProgressInputModal = useModal();
const {
data: recordings,
@@ -370,13 +418,6 @@ const RecordingTable = () => {
);
// ===== LOCATION, AREA, KANDANG OPTIONS =====
const locationParams = useMemo(() => {
if (filterLocationAreaId) {
return { area_id: filterLocationAreaId };
}
return undefined;
}, [filterLocationAreaId]);
const {
setInputValue: setLocationInputValue,
options: locationOptions,
@@ -387,7 +428,9 @@ const RecordingTable = () => {
'id',
'name',
'search',
locationParams
{
area_id: String(formik.values.area_id?.value),
}
);
const {
@@ -402,13 +445,6 @@ const RecordingTable = () => {
'search'
);
const projectFlockParams = useMemo(() => {
if (filterProjectFlockLocationId) {
return { location_id: filterProjectFlockLocationId };
}
return undefined;
}, [filterProjectFlockLocationId]);
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
@@ -420,34 +456,41 @@ const RecordingTable = () => {
'id',
'flock_name',
'search',
projectFlockParams
{
location_id: String(formik.values.location_id?.value),
}
);
const kandangOptions = useMemo(() => {
if (!filterProjectFlock || !projectFlocksRawData) return [];
if (!project_flock_id || !projectFlocksRawData) return [];
if (!isResponseSuccess(projectFlocksRawData)) return [];
const data = projectFlocksRawData.data as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === filterProjectFlock.value
const selectedProjectFlockData = data.find((pf) =>
pf.id === formik.values.project_flock_id?.value
? Number(formik.values.project_flock_id.value)
: 0
);
if (!selectedProjectFlockData?.kandangs) return [];
return selectedProjectFlockData.kandangs.map((k) => ({
value: k.id,
label: k.name || '',
}));
}, [filterProjectFlock, projectFlocksRawData]);
}, [project_flock_id, projectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => {
if (!filterProjectFlock || !filterKandang) return null;
if (!project_flock_id?.value || !kandang_id?.value) return null;
const params = new URLSearchParams({
project_flock_id: filterProjectFlock.value.toString(),
kandang_id: filterKandang.value.toString(),
project_flock_id: project_flock_id.value.toString(),
kandang_id: kandang_id.value.toString(),
});
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [filterProjectFlock, filterKandang]);
}, [project_flock_id, kandang_id]);
const { data: projectFlockKandangLookupData } = useSWR(
projectFlockKandangLookupUrl,
@@ -469,118 +512,45 @@ const RecordingTable = () => {
? projectFlockKandangLookupData.data
: undefined;
const formikRef = useRef(formik);
useEffect(() => {
formikRef.current = formik;
});
useEffect(() => {
if (projectFlockKandangLookup?.id) {
const pfkId = String(projectFlockKandangLookup.id);
setFilterProjectFlockKandangId(projectFlockKandangLookup.id);
formikRef.current.setFieldValue('project_flock_kandang_id', pfkId);
formik.setFieldValue('project_flock_kandang_id', pfkId);
} else {
setFilterProjectFlockKandangId(undefined);
formikRef.current.setFieldValue('project_flock_kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
}
}, [projectFlockKandangLookup]);
// ===== FILTER HANDLERS =====
const handleFilterAreaChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const area = val as OptionType | null;
const areaId = area?.value ? String(area.value) : null;
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('area_id', val);
formik.setFieldValue('location_id', null);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
formik.setFieldValue('area_id', areaId);
formik.setFieldValue('location_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
const handleFilterLocationChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('location_id', val);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
setFilterArea(area);
setFilterLocation(null);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterLocationAreaId(areaId || '');
setFilterProjectFlockLocationId('');
},
[formik]
);
const handleFilterProjectFlockChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('project_flock_id', val);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location?.value ? String(location.value) : null;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterLocation(location);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterProjectFlockLocationId(locationId || '');
},
[formik]
);
const handleFilterProjectFlockChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const projectFlock = val as OptionType | null;
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterProjectFlock(projectFlock);
setFilterKandang(null);
},
[formik]
);
const handleFilterKandangChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const kandang = val as OptionType | null;
const kandangId = kandang?.value ? String(kandang.value) : null;
formik.setFieldValue('kandang_id', kandangId);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterKandang(kandang);
},
[formik]
);
// ===== FILTER HELPERS =====
const areaIdValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const projectFlockIdValue = useMemo(() => {
if (!filterProjectFlock) return null;
return filterProjectFlock;
}, [filterProjectFlock]);
const kandangIdValue = useMemo(() => {
if (!formik.values.kandang_id) return null;
return (
kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
);
}, [formik.values.kandang_id, kandangOptions]);
const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang_id', val);
formik.setFieldValue('project_flock_kandang_id', null);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -588,25 +558,9 @@ const RecordingTable = () => {
formik.validateForm();
};
const isRecordingApproved = useCallback((recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
}, []);
useEffect(() => {
setTableState('recording-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value, true);
};
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
@@ -698,6 +652,60 @@ const RecordingTable = () => {
setIsLoadingExportingToExcel(false);
};
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressStartDate(e.target.value);
},
[]
);
const exportProgressEndDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressEndDate(e.target.value);
},
[]
);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await RecordingApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) {
const newSelection: Record<string, boolean> = {};
@@ -858,7 +866,8 @@ const RecordingTable = () => {
<>
<span>
{props.row.original.day} (Minggu ke-
{props.row.original.project_flock.production_standart.week})
{props.row.original.week} hari ke-
{props.row.original.excess_days})
</span>
</>
);
@@ -1104,7 +1113,7 @@ const RecordingTable = () => {
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
? `${value.toFixed(2)} butir`
: '-'}
</div>
);
@@ -1120,7 +1129,7 @@ const RecordingTable = () => {
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
? `${value.toFixed(2)} btr`
: '-'}
</div>
);
@@ -1368,6 +1377,16 @@ const RecordingTable = () => {
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
</div>
@@ -1446,13 +1465,13 @@ const RecordingTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={areaIdValue}
value={formik.values.area_id}
onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions}
@@ -1465,13 +1484,13 @@ const RecordingTable = () => {
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationIdValue}
value={formik.values.location_id}
onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
onMenuScrollToBottom={loadMoreLocations}
isDisabled={!filterArea}
isDisabled={!formik.values.area_id?.value}
className={{ wrapper: 'w-full' }}
/>
@@ -1479,13 +1498,13 @@ const RecordingTable = () => {
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={projectFlockIdValue}
value={formik.values.project_flock_id}
onChange={handleFilterProjectFlockChange}
onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlocks}
isClearable
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!filterLocation}
isDisabled={!formik.values.location_id?.value}
className={{ wrapper: 'w-full' }}
/>
@@ -1493,11 +1512,35 @@ const RecordingTable = () => {
label='Kandang'
placeholder='Pilih Kandang'
options={kandangOptions}
value={kandangIdValue}
value={formik.values.kandang_id}
onChange={handleFilterKandangChange}
isLoading={!filterProjectFlock}
isLoading={!formik.values.project_flock_id?.value}
isClearable
isDisabled={!formik.values.project_flock_id?.value}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={projectFlockCategoryOptions}
value={formik.values.project_flock_category}
onChange={(val) => {
formik.setFieldValue('project_flock_category', val);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status Approval'
placeholder='Pilih Status Approval'
options={recordingApprovalStatusOptions}
value={formik.values.approval_status}
onChange={(val) => {
formik.setFieldValue('approval_status', val);
}}
isClearable
isDisabled={!filterProjectFlock}
className={{ wrapper: 'w-full' }}
/>
</div>
@@ -1505,30 +1548,16 @@ const RecordingTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
setFilterArea(null);
setFilterLocation(null);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterLocationAreaId('');
setFilterProjectFlockLocationId('');
filterModal.closeModal();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={
!formik.isValid ||
formik.isSubmitting ||
!formik.values.kandang_id
}
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
@@ -1551,6 +1580,76 @@ const RecordingTable = () => {
}}
/>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
@@ -1,15 +1,40 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const RecordingFilterSchema = object().shape({
area_id: string().nullable(),
location_id: string().nullable(),
kandang_id: string().nullable(),
project_flock_kandang_id: string().nullable(),
export const RecordingFilterSchema = Yup.object().shape({
area_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
location_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
kandang_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_kandang_id: Yup.number().nullable(),
approval_status: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type RecordingFilterType = {
area_id: string | null;
location_id: string | null;
kandang_id: string | null;
project_flock_kandang_id: string | null;
area_id: OptionType<number> | null;
location_id: OptionType<number> | null;
project_flock_id: OptionType<number> | null;
kandang_id: OptionType<number> | null;
project_flock_kandang_id: number | null;
approval_status: OptionType<string> | null;
project_flock_category: OptionType<string> | null;
};
@@ -4,7 +4,9 @@ import {
CreateGrowingRecordingPayload,
CreateLayingRecordingPayload,
CreateEggPayload,
RecordingStock,
} from '@/types/api/production/recording';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type RecordingGrowingFormSchemaType = {
record_date: string;
@@ -29,11 +31,19 @@ type RecordingGrowingFormSchemaType = {
} | null;
project_flock_kandang_id: number;
stocks: {
product_warehouse_id: number;
product_warehouse_id:
| {
value: number;
label: string;
}
| undefined;
qty: number | string;
}[];
depletions: {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string;
}[];
@@ -41,34 +51,48 @@ type RecordingGrowingFormSchemaType = {
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
qty?: number | string;
weight?: number | string;
}[];
};
export type StockSchema = {
product_warehouse_id: number;
product_warehouse_id: {
value: number;
label: string;
};
qty: number | string;
};
export type DepletionSchema = {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string;
};
export type EggSchema = {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
qty?: number | string;
weight?: number | string;
};
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
product_warehouse_id: Yup.number()
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk harus berupa angka!'),
.typeError('Produk wajib diisi!'),
qty: Yup.number()
.required('Jumlah penggunaan wajib diisi!')
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
@@ -76,9 +100,12 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
});
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number()
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.optional()
.typeError('Depletions harus berupa angka!'),
.nullable(),
source_product_warehouse_id: Yup.number()
.optional()
.typeError('Gudang sumber harus berupa angka!'),
@@ -88,9 +115,12 @@ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
});
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.number()
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.optional()
.typeError('Kondisi telur harus berupa angka!'),
.nullable(),
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
});
@@ -248,14 +278,18 @@ export const getRecordingGrowingFormInitialValues = (
initialValues?.project_flock?.project_flock_kandang_id ??
0,
stocks: initialValues?.stocks?.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
product_warehouse_id: {
value: stock.product_warehouse_id,
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty:
(stock as { qty?: number; usage_amount?: number }).qty ||
(stock as { qty?: number; usage_amount?: number }).usage_amount ||
(stock as RecordingStock).qty ||
((stock as RecordingStock).usage_amount || 0) +
((stock as RecordingStock).pending_qty || 0) ||
'',
})) ?? [
{
product_warehouse_id: 0,
product_warehouse_id: undefined,
qty: '',
},
],
@@ -263,13 +297,16 @@ export const getRecordingGrowingFormInitialValues = (
(
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
) => ({
product_warehouse_id: depletion.product_warehouse_id,
product_warehouse_id: {
value: Number(depletion.product_warehouse_id ?? 0),
label: getProductWarehouseOptionLabel(depletion.product_warehouse),
},
source_product_warehouse_id: depletion.source_product_warehouse_id,
qty: depletion.qty,
})
) ?? [
{
product_warehouse_id: 0,
product_warehouse_id: undefined,
qty: '',
},
],
@@ -281,12 +318,15 @@ export const getRecordingLayingFormInitialValues = (
...getRecordingGrowingFormInitialValues(initialValues),
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: egg.product_warehouse_id,
product_warehouse_id: {
value: Number(egg.product_warehouse_id ?? 0),
label: getProductWarehouseOptionLabel(egg.product_warehouse),
},
qty: egg.qty,
weight: egg.weight,
})) ?? [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
@@ -31,12 +31,14 @@ import {
RecordingApi,
ProjectFlockApi,
} from '@/services/api/production';
import { ProductionStandardApi } from '@/services/api/master-data';
import { ProductionStandardApi, ProductApi } from '@/services/api/master-data';
import {
ProductionStandard,
StandardDetails,
} from '@/types/api/master-data/production-standard';
import { Product } from '@/types/api/master-data/product';
import { LocationApi } from '@/services/api/master-data';
import { SystemSettingsApi } from '@/services/api/system-settings';
import { ProductWarehouseApi } from '@/services/api/inventory';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
@@ -499,6 +501,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
type,
]);
// ===== MIGRATION MODE =====
const { data: systemSettingsResponse } = useSWR(
SystemSettingsApi.basePath,
SystemSettingsApi.getAllFetcher
);
const isMigrationMode = useMemo(() => {
if (!isResponseSuccess(systemSettingsResponse)) return false;
const setting = systemSettingsResponse.data.find(
(s) => s.key === 'allow_negative_pakan_ovk'
);
return setting?.value === 'true';
}, [systemSettingsResponse]);
// ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => {
@@ -506,7 +522,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
...(depletion.source_product_warehouse_id && {
source_product_warehouse_id:
depletion.source_product_warehouse_id,
@@ -517,9 +533,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const stocks = recordingRestriction.canEditStock
? (values.stocks ?? [])
.filter((s) => s.product_warehouse_id && s.qty)
.filter((s) => s.product_warehouse_id?.value && s.qty)
.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
// In migration mode, product_warehouse_id field holds product.id;
// send it as product_id so the backend auto-creates the warehouse entry.
...(isMigrationMode
? { product_id: stock.product_warehouse_id?.value }
: { product_warehouse_id: stock.product_warehouse_id?.value }),
qty: Number(stock.qty) || 0,
}))
: [];
@@ -531,15 +551,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(depletions.length > 0 && { depletions }),
};
},
[recordingRestriction.canEditStock, recordingRestriction.canEditDepletion]
[
isMigrationMode,
recordingRestriction.canEditStock,
recordingRestriction.canEditDepletion,
]
);
const createLayingPayload = useCallback(
(values: RecordingLayingFormValues) => {
const depletions = values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
?.filter((d) => d.product_warehouse_id?.value && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
...(depletion.source_product_warehouse_id && {
source_product_warehouse_id: depletion.source_product_warehouse_id,
}),
@@ -549,7 +573,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const eggs = values.eggs
?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
.map((egg) => ({
product_warehouse_id: egg.product_warehouse_id!,
product_warehouse_id: egg.product_warehouse_id?.value ?? 0,
qty: Number(egg.qty) || 0,
weight:
typeof egg.weight === 'number'
@@ -559,9 +583,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const stocks = recordingRestriction.canEditStock
? values.stocks
.filter((s) => s.product_warehouse_id && s.qty)
.filter((s) => s.product_warehouse_id?.value && s.qty)
.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
...(isMigrationMode
? { product_id: stock.product_warehouse_id?.value }
: { product_warehouse_id: stock.product_warehouse_id?.value }),
qty: Number(stock.qty) || 0,
}))
: [];
@@ -574,7 +600,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(eggs && eggs.length > 0 && { eggs }),
};
},
[recordingRestriction.canEditStock]
[isMigrationMode, recordingRestriction.canEditStock]
);
const isRecordingEditable = useCallback((recording?: Recording) => {
@@ -603,11 +629,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return true;
}, []);
// When migration mode ON: fetch all master PAKAN/OVK products (no warehouse entry needed).
// When migration mode OFF: fetch from product-warehouses as usual.
const {
setInputValue: setStockProductInputValue,
rawData: stockProducts,
isLoadingOptions: isLoadingStockProducts,
loadMore: loadMoreStockProducts,
rawData: stockProductsPW,
isLoadingOptions: isLoadingStockProductsPW,
loadMore: loadMoreStockProductsPW,
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', 'search', {
flags: 'PAKAN,OVK',
limit: '100',
@@ -616,6 +644,29 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(selectedKandangId ? { kandang_id: selectedKandangId.toString() } : {}),
});
const {
setInputValue: setStockMasterInputValue,
rawData: stockProductsMaster,
isLoadingOptions: isLoadingStockProductsMaster,
loadMore: loadMoreStockProductsMaster,
} = useSelect(
isMigrationMode ? ProductApi.basePath : null,
'id',
'name',
'search',
{ flags: 'PAKAN,OVK', limit: '100' }
);
const isLoadingStockProducts = isMigrationMode
? isLoadingStockProductsMaster
: isLoadingStockProductsPW;
const loadMoreStockProducts = isMigrationMode
? loadMoreStockProductsMaster
: loadMoreStockProductsPW;
const setStockInputValue = isMigrationMode
? setStockMasterInputValue
: setStockProductInputValue;
const {
rawData: depletionProductsData,
isLoadingOptions: isLoadingDepletionProducts,
@@ -999,9 +1050,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
useEffect(() => {
const items: Array<ProductWarehouse | null | undefined> = [];
if (isResponseSuccess(stockProducts)) {
if (!isMigrationMode && isResponseSuccess(stockProductsPW)) {
items.push(
...((stockProducts.data as unknown as ProductWarehouse[]) ?? [])
...((stockProductsPW.data as unknown as ProductWarehouse[]) ?? [])
);
}
@@ -1035,7 +1086,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
mergeKnownProductWarehouses(items);
}, [
stockProducts,
isMigrationMode,
stockProductsPW,
depletionProductsData,
eggProductsData,
initialValues,
@@ -1066,9 +1118,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
);
const unifiedStockProducts = useMemo(() => {
const options = isResponseSuccess(stockProducts)
if (isMigrationMode) {
// In migration mode, show all master PAKAN/OVK products (no warehouse context).
// value = product.id; submission will send product_id to the backend.
const options: OptionType[] = isResponseSuccess(stockProductsMaster)
? (stockProductsMaster.data as unknown as Product[])
.map((p) => ({ value: p.id, label: p.name }))
.sort((a, b) => a.label.localeCompare(b.label))
: [];
return options;
}
const options = isResponseSuccess(stockProductsPW)
? buildProductWarehouseOptions(
stockProducts.data as unknown as ProductWarehouse[]
stockProductsPW.data as unknown as ProductWarehouse[]
)
: [];
@@ -1085,7 +1148,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return options;
}, [
stockProducts,
isMigrationMode,
stockProductsMaster,
stockProductsPW,
buildProductWarehouseOptions,
initialValues,
type,
@@ -1204,6 +1269,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
};
}
// In migration mode (edit), the dropdown options use product.id as their value,
// but the API returns product_warehouse_id (PW entity ID). Remap so the dropdown
// can match the correct option. The product ID is available on the nested
// product_warehouse object returned by the API.
if (isMigrationMode && type === 'edit' && initialValues?.stocks?.length) {
baseValues.stocks = initialValues.stocks.map((stock) => ({
product_warehouse_id: {
value: Number(
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
),
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty: stock.usage_amount ?? '',
}));
}
if (!recordingRestriction.canEditStock) {
baseValues.stocks = [];
}
@@ -1224,6 +1305,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
selectedKandang,
recordingRestriction.canEditStock,
recordingRestriction.canEditDepletion,
isMigrationMode,
]);
const formik = useFormik<
@@ -1335,6 +1417,35 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
},
});
// SWR timing fix: formik initializes before system-settings load, so isMigrationMode
// starts false. When it flips true, formikInitialValues recomputes but enableReinitialize
// is false, so formik won't pick it up. Push the corrected stock values once, and only
// once — the ref prevents re-firing if something causes isMigrationMode to re-evaluate.
const migrationEditMappingApplied = useRef(false);
useEffect(() => {
if (
type !== 'edit' ||
!isMigrationMode ||
!initialValues?.stocks?.length ||
migrationEditMappingApplied.current
)
return;
migrationEditMappingApplied.current = true;
formik.setFieldValue(
'stocks',
initialValues.stocks.map((stock) => ({
product_warehouse_id: {
value: Number(
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
),
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty: stock.usage_amount ?? '',
}))
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMigrationMode]);
// ===== HELPER FUNCTIONS =====
const { setFieldValue } = formik;
@@ -1351,7 +1462,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(stockIdx: number) => {
if ((type as 'add' | 'edit' | 'detail') === 'detail') return null;
const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id) return null;
if (!stock || !stock.product_warehouse_id?.value) return null;
return null;
},
[formik.values.stocks, type]
@@ -1361,7 +1472,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(productWarehouseId: number) => {
if ((type === 'edit' || type === 'detail') && initialValues?.stocks) {
const existingStock = initialValues.stocks.find(
(s) => s.product_warehouse_id === productWarehouseId
(s) => Number(s.product_warehouse_id) === Number(productWarehouseId)
) as RecordingStock | undefined;
if (existingStock) {
return {
@@ -1381,21 +1492,25 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const getStockUsageAdornment = useCallback(
(stockIdx: number) => {
const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id) return null;
if (!stock || !stock.product_warehouse_id?.value) return null;
const isDetail = (type as 'add' | 'edit' | 'detail') === 'detail';
const availableStock = getAvailableStock(stock.product_warehouse_id);
const availableStock = getAvailableStock(
stock.product_warehouse_id.value
);
const requestedUsage = Number(stock.qty) || 0;
const remainingStock = availableStock - requestedUsage;
const { pendingQty } = getStockPendingInfo(stock.product_warehouse_id);
const { pendingQty } = getStockPendingInfo(
stock.product_warehouse_id.value
);
if (isDetail) {
if (pendingQty > 0) {
return (
<span className='text-sm text-gray-600 whitespace-nowrap'>
(tersedia: {formatNumber(requestedUsage)} | pending:{' '}
(tersedia: {formatNumber(availableStock)} | pending:{' '}
<span className='text-error'>{formatNumber(pendingQty)}</span> |
pakai: {formatNumber(requestedUsage + pendingQty)})
pakai: {formatNumber(requestedUsage)})
</span>
);
}
@@ -1494,10 +1609,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return (
idx !== currentIdx &&
s.product_warehouse_id &&
s.product_warehouse_id !== 0
s.product_warehouse_id.value !== 0
);
})
.map((s) => s.product_warehouse_id) || [];
.map((s) => s.product_warehouse_id?.value) || [];
return unifiedStockProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1514,10 +1629,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return (
idx !== currentIdx &&
d.product_warehouse_id &&
d.product_warehouse_id !== 0
d.product_warehouse_id.value !== 0
);
})
.map((d) => d.product_warehouse_id) || [];
.map((d) => d.product_warehouse_id?.value) || [];
return depletionProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1534,10 +1649,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return (
idx !== currentIdx &&
e.product_warehouse_id &&
e.product_warehouse_id !== 0
e.product_warehouse_id.value !== 0
);
})
.map((e) => e.product_warehouse_id) || [];
.map((e) => e.product_warehouse_id?.value) || [];
return eggProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1583,7 +1698,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isError: touchedField && Boolean(errorField?.[column]),
errorMessage:
touchedField && errorField?.[column]
? (errorField[column] as string)
? errorField[column] instanceof Object
? (errorField[column] as OptionType)?.label
: (errorField[column] as string)
: '',
};
};
@@ -1614,14 +1731,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
@@ -1629,7 +1746,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1678,14 +1795,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
@@ -1693,7 +1810,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1731,14 +1848,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
@@ -1746,7 +1863,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1959,7 +2076,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newStocks = [
...(formik.values.stocks || []),
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
];
@@ -1991,7 +2108,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newDepletions = [
...(formik.values.depletions || []),
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
];
@@ -2025,7 +2142,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newEggs = [
...((formik.values as RecordingLayingFormValues).eggs || []),
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
];
@@ -2068,7 +2185,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') {
const layingValues = formik.values as RecordingLayingFormValues;
if (!layingValues.eggs || layingValues.eggs.length === 0) {
setFieldValue('eggs', [{ product_warehouse_id: 0, qty: '' }]);
setFieldValue('eggs', [{ product_warehouse_id: null, qty: '' }]);
}
}
}, [isLayingCategory, type, formik.values, setFieldValue]);
@@ -2790,20 +2907,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<td>
<SelectInput
required
key={`stock-product-${idx}-${stock.product_warehouse_id}`}
value={
unifiedStockProducts.find(
(product) =>
product.value === stock.product_warehouse_id
) || null
}
onInputChange={setStockProductInputValue}
key={`stock-product-${idx}-${stock.product_warehouse_id?.value}`}
value={stock.product_warehouse_id}
onInputChange={setStockInputValue}
onChange={(selectedOption) => {
const option =
selectedOption as OptionType | null;
formik.setFieldValue(
`stocks.${idx}.product_warehouse_id`,
option?.value || 0
option
);
}}
options={getAvailableStockProductOptions(idx)}
@@ -2839,9 +2951,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}
isClearable={type !== 'detail'}
inputPrefix={
stock.product_warehouse_id
stock.product_warehouse_id?.value
? getProductFlagBadgeAdornment(
stock.product_warehouse_id
stock.product_warehouse_id.value
)
: undefined
}
@@ -2877,7 +2989,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
inputSuffix={
stock.product_warehouse_id
? getProductUomSuffix(
stock.product_warehouse_id,
stock.product_warehouse_id.value,
'stock'
)
: null
@@ -3070,19 +3182,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)}
<td>
<SelectInput
value={
depletionProducts.find(
(product) =>
product.value ===
depletion.product_warehouse_id
) || null
}
value={depletion.product_warehouse_id}
onChange={(selectedOption) => {
const option =
selectedOption as OptionType | null;
formik.setFieldValue(
`depletions.${idx}.product_warehouse_id`,
option?.value || 0
option
);
}}
options={getAvailableDepletionProductOptions(idx)}
@@ -3145,7 +3251,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
inputSuffix={
depletion.product_warehouse_id
? getProductUomSuffix(
depletion.product_warehouse_id,
depletion.product_warehouse_id.value,
'depletion'
)
: null
@@ -3323,18 +3429,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)}
<td>
<SelectInput
value={
eggProducts.find(
(product) =>
product.value === egg.product_warehouse_id
) || null
}
value={egg.product_warehouse_id}
onChange={(selectedOption) => {
const option =
selectedOption as OptionType | null;
formik.setFieldValue(
`eggs.${idx}.product_warehouse_id`,
option?.value || 0
option
);
}}
options={getAvailableEggProductOptions(idx)}
@@ -40,6 +40,9 @@ const TransferToLayingDetailModal = () => {
? transferToLayingResponse.data
: undefined;
const isTransferToLayingApproved =
transferToLaying?.approval.step_number === 2;
const { data: transferToLayingApprovalResponse } = useSWR(
transferToLayingId
? ['approval-transfer-to-laying', transferToLayingId]
@@ -55,9 +58,9 @@ const TransferToLayingDetailModal = () => {
const detailModal = useModal();
const totalEnteredChickenForTransfer =
const maxSourceQuantity =
transferToLaying?.sources.reduce(
(acc, item) => acc + Number(item.qty),
(acc, item) => acc + Number(item.product_warehouse.quantity),
0
) ?? 0;
@@ -67,8 +70,9 @@ const TransferToLayingDetailModal = () => {
0
) ?? 0;
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
const totalAvailableChickenForTransfer =
totalEnteredChickenForTransfer - totalTransferedChicken;
maxSourceQuantity - totalTransferedChicken;
const closeModalHandler = (shouldPushToRoute: boolean = true) => {
if (shouldPushToRoute) {
@@ -161,11 +165,34 @@ const TransferToLayingDetailModal = () => {
{/* Source Kandang */}
<div className='flex flex-col'>
<span className='w-full py-2 text-xs font-semibold'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
<span className='text-nowrap'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</span>
{!isTransferToLayingApproved && (
<>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa ayam: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</>
)}
</span>
{transferToLaying?.sources.length === 0 && (
@@ -225,21 +252,6 @@ const TransferToLayingDetailModal = () => {
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0 ? 'error' : 'neutral'
}
text={`Sisa transfer: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{transferToLaying?.targets.length === 0 && (
@@ -304,7 +316,7 @@ const TransferToLayingDetailModal = () => {
readOnly
errorMessage={
totalAvailableChickenForTransfer < 0
? `Jumlah transfer melebihi ketersediaan (${formatNumber(totalEnteredChickenForTransfer, 'en-US')} ayam)`
? `Jumlah transfer melebihi ketersediaan (${formatNumber(maxSourceQuantity, 'en-US')} ayam)`
: ''
}
/>
@@ -13,7 +13,6 @@ import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { ProjectFlockApi } from '@/services/api/production';
import { Flock } from '@/types/api/master-data/flock';
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
import {
TransferToLayingFilterSchema,
TransferToLayingFilterValues,
@@ -21,12 +20,14 @@ import {
interface TransferToLayingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: TransferToLayingFilter) => void;
initialValues?: Partial<TransferToLayingFilterValues>;
onSubmit?: (values: TransferToLayingFilterValues) => void;
onReset?: () => void;
}
const TransferToLayingFilterModal = ({
ref,
initialValues: initialValuesProp,
onSubmit,
onReset,
}: TransferToLayingFilterModal) => {
@@ -86,28 +87,16 @@ const TransferToLayingFilterModal = ({
const formik = useFormik<TransferToLayingFilterValues>({
initialValues: {
startDate: '',
endDate: '',
flockSource: [],
flockDestination: [],
status: [],
startDate: initialValuesProp?.startDate ?? '',
endDate: initialValuesProp?.endDate ?? '',
flockSource: initialValuesProp?.flockSource ?? [],
flockDestination: initialValuesProp?.flockDestination ?? [],
status: initialValuesProp?.status ?? [],
},
enableReinitialize: true,
validationSchema: TransferToLayingFilterSchema,
onSubmit: async (values) => {
const formattedValues = {
...values,
flockSource: values.flockSource
? (values.flockSource as OptionType[]).map((item) => item.value)
: [],
flockDestination: values.flockDestination
? (values.flockDestination as OptionType[]).map((item) => item.value)
: [],
status: values.status
? (values.status as OptionType[]).map((item) => item.value)
: [],
};
onSubmit?.(formattedValues as TransferToLayingFilter);
onSubmit?.(values);
closeModalHandler();
},
onReset: () => {
@@ -223,6 +223,8 @@ const TransferToLayingFormModal = () => {
},
});
const { flockSource: formikFlockSource } = formik.values;
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState<
@@ -455,13 +457,13 @@ const TransferToLayingFormModal = () => {
useEffect(() => {
if (isResponseSuccess(flockSourceRawData)) {
const selectedFlockSourceRawData = flockSourceRawData.data.find(
const currentSelectedFlockSourceRawData = flockSourceRawData.data.find(
(item) => item.id === formik.values.flockSource?.value
);
setSelectedFlockSourceRawData(selectedFlockSourceRawData);
setSelectedFlockSourceRawData(currentSelectedFlockSourceRawData);
}
}, [flockSourceRawData]);
}, [flockSourceRawData, formikFlockSource]);
useEffect(() => {
formik.setFieldValue('totalQuantity', totalTransferedChicken);
@@ -625,6 +627,7 @@ const TransferToLayingFormModal = () => {
>
<div className='flex flex-row items-center gap-3'>
<input
id={`flock-source-kandang-${item.project_flock_kandang_id}`}
type='radio'
name='flockSourceKandang'
value={item.project_flock_kandang_id}
@@ -637,13 +640,14 @@ const TransferToLayingFormModal = () => {
/>
<label
htmlFor={`flock-source-kandang-${item.project_flock_kandang_id}`}
className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable,
'cursor-not-allowed opacity-50': !isAvailable,
})}
>
{item.kandang_name}{' '}
<span className='text-base-content/20'>{`(Max: ${item.available_qty})`}</span>
<span className='text-base-content/20'>{`(Max: ${item.available_qty ?? '-'})`}</span>
</label>
</div>
@@ -818,11 +822,33 @@ const TransferToLayingFormModal = () => {
{/* Source Kandang */}
<div className='flex flex-col'>
<span className='w-full py-2 text-xs font-semibold'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
<span className='text-nowrap'>
Kandang Asal{' '}
<span
className='tooltip tooltip-error'
data-tip='required'
>
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa ayam: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{formik.values.flockSourceKandangs.length === 0 && (
@@ -902,23 +928,6 @@ const TransferToLayingFormModal = () => {
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa transfer: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{formik.values.flockDestinationKandangs.length === 0 && (
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr';
@@ -26,10 +26,9 @@ import TransferToLayingFilterModal from '@/components/pages/production/transfer-
import TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal';
import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton';
import {
TransferToLaying,
TransferToLayingFilter,
} from '@/types/api/production/transfer-to-laying';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
import { TransferToLayingFilterValues } from '@/components/pages/production/transfer-to-laying/filter/TransferToLayingFilter';
import { OptionType } from '@/components/input/SelectInput';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -142,6 +141,8 @@ const TransferToLayingsTable = () => {
status: '',
filter_by: '',
sort_by: '',
flockSourceNames: '',
flockDestinationNames: '',
},
paramMap: {
page: 'page',
@@ -154,6 +155,9 @@ const TransferToLayingsTable = () => {
filter_by: 'filter_by',
sort_by: 'sort_by',
},
excludeKeysFromUrl: ['flockSourceNames', 'flockDestinationNames'],
persist: true,
storeName: 'transfer-to-laying-table',
});
const {
@@ -431,12 +435,84 @@ const TransferToLayingsTable = () => {
updateFilter('search', e.target.value);
};
const filterSubmitHandler = (values: TransferToLayingFilter) => {
updateFilter('startDate', values.startDate);
updateFilter('endDate', values.endDate);
updateFilter('flockSource', values.flockSource.join(','));
updateFilter('flockDestination', values.flockDestination.join(','));
updateFilter('status', values.status.join(','));
const STATUS_FILTER_OPTIONS = [
{ value: 'PENDING', label: 'Pengajuan' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const filterModalInitialValues = useMemo(() => {
const flockSourceIds = tableFilterState.flockSource
? tableFilterState.flockSource.split(',')
: [];
const flockSourceNameList = tableFilterState.flockSourceNames
? tableFilterState.flockSourceNames.split(',')
: [];
const flockSourceOptions = flockSourceIds.filter(Boolean).map((id, i) => ({
value: parseInt(id),
label: flockSourceNameList[i] || id,
}));
const flockDestIds = tableFilterState.flockDestination
? tableFilterState.flockDestination.split(',')
: [];
const flockDestNameList = tableFilterState.flockDestinationNames
? tableFilterState.flockDestinationNames.split(',')
: [];
const flockDestOptions = flockDestIds.filter(Boolean).map((id, i) => ({
value: parseInt(id),
label: flockDestNameList[i] || id,
}));
const statusIds = tableFilterState.status
? tableFilterState.status.split(',')
: [];
const statusOptions = statusIds.filter(Boolean).map((id) => {
const found = STATUS_FILTER_OPTIONS.find((opt) => opt.value === id);
return found || { value: id, label: id };
});
return {
startDate: tableFilterState.startDate || '',
endDate: tableFilterState.endDate || '',
flockSource: flockSourceOptions,
flockDestination: flockDestOptions,
status: statusOptions,
};
}, [
tableFilterState.startDate,
tableFilterState.endDate,
tableFilterState.flockSource,
tableFilterState.flockDestination,
tableFilterState.status,
tableFilterState.flockSourceNames,
tableFilterState.flockDestinationNames,
]);
const filterSubmitHandler = (values: TransferToLayingFilterValues) => {
const flockSourceOpts = (values.flockSource as OptionType[]) || [];
const flockDestOpts = (values.flockDestination as OptionType[]) || [];
const statusOpts = (values.status as OptionType[]) || [];
updateFilter('startDate', values.startDate || '');
updateFilter('endDate', values.endDate || '');
updateFilter(
'flockSource',
flockSourceOpts.map((o) => String(o.value)).join(',')
);
updateFilter(
'flockDestination',
flockDestOpts.map((o) => String(o.value)).join(',')
);
updateFilter('status', statusOpts.map((o) => String(o.value)).join(','));
updateFilter(
'flockSourceNames',
flockSourceOpts.map((o) => String(o.label)).join(',')
);
updateFilter(
'flockDestinationNames',
flockDestOpts.map((o) => String(o.label)).join(',')
);
};
const filterResetHandler = () => {
@@ -445,6 +521,8 @@ const TransferToLayingsTable = () => {
updateFilter('flockSource', '');
updateFilter('flockDestination', '');
updateFilter('status', '');
updateFilter('flockSourceNames', '');
updateFilter('flockDestinationNames', '');
};
const exportToExcelHandler = async () => {
@@ -558,6 +636,8 @@ const TransferToLayingsTable = () => {
'search',
'filter_by',
'sort_by',
'flockSourceNames',
'flockDestinationNames',
]}
fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal}
@@ -670,6 +750,7 @@ const TransferToLayingsTable = () => {
<TransferToLayingFilterModal
ref={filterModal.ref}
initialValues={filterModalInitialValues}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useState, useEffect } from 'react';
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -9,31 +9,49 @@ import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { PurchaseFilter } from '@/types/api/purchase/purchase';
import { AreaApi, LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: {
poDate: string;
category: OptionType<number>[];
status: OptionType<string>[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
onSubmit?: (values: PurchaseFilter) => void;
onReset?: () => void;
}
const PurchaseFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: PurchaseFilterModalProps) => {
const closeModalHandler = () => {
const closeModalHandler = useCallback(() => {
ref.current?.close();
};
}, [ref]);
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => {
@@ -73,32 +91,134 @@ const PurchaseFilterModal = ({
'search'
);
const [selectedAreaId, setSelectedAreaId] = useState(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
const [selectedLocationId, setSelectedLocationId] = useState(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedAreaId || '',
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<{
poDate: string;
category: { label: string; value: number }[];
status: { label: string; value: string }[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
}>({
initialValues: {
// enableReinitialize: true,
initialValues: initialValues || {
poDate: '',
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
onSubmit: async (values) => {
const formattedValues = {
...values,
category: values.category.map((item) => String(item.value)),
category_labels: values.category,
status: values.status.map((item) => String(item.value)),
supplier_id: values.supplier?.value,
supplier_label: values.supplier?.label,
area_id: values.area?.value,
area_label: values.area?.label,
location_id: values.location?.value,
location_label: values.location?.label,
project_flock_id: values.project_flock?.value,
project_flock_label: values.project_flock?.label,
project_flock_kandang_id: values.project_flock_kandang?.value,
project_flock_kandang_label: values.project_flock_kandang?.label,
};
onSubmit?.(formattedValues);
closeModalHandler();
},
onReset: () => {
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
},
});
const { resetForm, submitForm } = formik;
useEffect(() => {
setSelectedAreaId(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.area, initialValues?.location]);
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
const productCategoryChangeHandler = (
val: OptionType | OptionType[] | null
) => {
@@ -109,6 +229,29 @@ const PurchaseFilterModal = ({
formik.setFieldValue('status', val);
};
const formikResetHandler = useCallback(() => {
resetForm({
values: {
poDate: '',
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const formikSubmitHandler = useCallback(async () => {
await submitForm();
}, [submitForm]);
return (
<Modal
ref={ref}
@@ -118,7 +261,7 @@ const PurchaseFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -132,7 +275,9 @@ const PurchaseFilterModal = ({
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
onClick={() => {
closeModalHandler();
}}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
@@ -172,6 +317,108 @@ const PurchaseFilterModal = ({
value: item.step_name,
}))}
/>
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
value={formik.values.supplier}
onChange={(val) =>
formik.setFieldValue(
'supplier',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isClearable
/>
<SelectInput
label='Area'
placeholder='Pilih Area'
value={formik.values.area}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('area', nextValue);
formik.setFieldValue('location', null);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedAreaId(
nextValue?.value ? String(nextValue.value) : ''
);
setSelectedLocationId('');
}}
options={areaOptions}
isLoading={isLoadingAreaOptions}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
value={formik.values.location}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('location', nextValue);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(
nextValue?.value ? String(nextValue.value) : ''
);
}}
options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!formik.values.area}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
value={formik.values.project_flock}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('project_flock', nextValue);
formik.setFieldValue('project_flock_kandang', null);
}}
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!formik.values.location}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={projectFlockKandangOptions}
isClearable
isDisabled={!formik.values.project_flock}
/>
</div>
</div>
@@ -187,7 +434,8 @@ const PurchaseFilterModal = ({
</Button>
<Button
type='submit'
type='button'
onClick={formikSubmitHandler}
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
+446 -91
View File
@@ -1,25 +1,22 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import {
CellContext,
ColumnDef,
SortingState,
Updater,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import DateInput from '@/components/input/DateInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -28,17 +25,37 @@ import StatusBadge from '@/components/helper/StatusBadge';
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter';
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
import Dropdown from '@/components/dropdown/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import { cn, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
type PurchaseTableFilters = {
search: string;
sort_by: string;
order_by: string;
po_date: string;
approval_status: string;
product_category_id: string;
product_category_name: string;
supplier_id: string;
supplier_name: string;
area_id: string;
area_name: string;
location_id: string;
location_name: string;
project_flock_id: string;
project_flock_name: string;
project_flock_kandang_id: string;
project_flock_kandang_name: string;
};
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -147,42 +164,94 @@ const RowOptionsMenu = ({
};
const PurchaseTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [sorting, setSorting] = useState<SortingState>([]);
// ===== TABLE FILTER STATE =====
const {
state: tableFilterState,
setFilters,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<PurchaseTableFilters>({
initial: {
search: '',
sort_by: '',
order_by: '',
po_date: '',
approval_status: '',
product_category_id: '',
product_category_name: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
sort_by: 'sort_by',
order_by: 'sort_order',
po_date: 'po_date',
approval_status: 'approval_status',
product_category_id: 'product_category_id',
supplier_id: 'supplier_id',
area_id: 'area_id',
location_id: 'location_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
},
excludeKeysFromUrl: [
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'purchase-table',
});
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// ===== MODAL HOOKS =====
const filterModal = useModal();
const deleteModal = useModal();
const exportProgressInputModal = useModal();
// ===== API DATA FETCHING =====
const {
@@ -194,36 +263,10 @@ const PurchaseTable = () => {
PurchaseApi.getAllFetcher
);
const getKey = (
pageIndex: number,
previousPageData: BaseApiResponse<Expense>[] | null
) => {
if (pageIndex > 0 && !previousPageData) return null;
return `${ExpenseApi.basePath}?page=${pageIndex + 1}&limit=100`;
};
const { data: expensesPages } = useSWRInfinite(
getKey,
ExpenseApi.getAllFetcher
);
const expenseMap = useMemo(() => {
const map = new Map<string, number>();
if (!expensesPages) return map;
expensesPages.forEach((page) => {
if (isResponseSuccess(page)) {
page.data.forEach((expense: Expense) => {
map.set(expense.reference_number, expense.id);
});
}
});
return map;
}, [expensesPages]);
// ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [
{
accessorKey: 'po_number',
header: 'No. PR/PO',
cell: (props) => {
const { pr_number, po_number } = props.row.original;
@@ -239,20 +282,16 @@ const PurchaseTable = () => {
return (
<ul className='list-disc pl-4'>
{poExpedition.map((exp, index) => {
const expenseId = expenseMap.get(exp.refrence);
if (expenseId) {
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${expenseId}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
}
return <li key={index}>{exp.refrence}</li>;
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${exp.id}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
})}
</ul>
);
@@ -269,7 +308,7 @@ const PurchaseTable = () => {
cell: (props) => props.row.original.requester_name || '-',
},
{
accessorKey: 'products.name',
accessorKey: 'products',
header: 'Produk',
cell: (props) => {
const products = props.row.original.products;
@@ -284,7 +323,7 @@ const PurchaseTable = () => {
},
},
{
accessorKey: 'location.name',
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
@@ -296,6 +335,14 @@ const PurchaseTable = () => {
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'received_date',
header: 'Tgl. Terima',
cell: (props) =>
props.row.original.received_date
? formatDate(props.row.original.received_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
@@ -306,6 +353,7 @@ const PurchaseTable = () => {
},
{
header: 'Aging',
enableSorting: false,
cell: (props) => {
const purchase = props.row.original;
if (!purchase.po_date) return '-';
@@ -317,6 +365,7 @@ const PurchaseTable = () => {
},
},
{
accessorKey: 'status',
header: 'Status Approval',
cell: (props) => {
const approval = props.row.original.latest_approval;
@@ -361,6 +410,14 @@ const PurchaseTable = () => {
);
},
},
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM YYYY')
: '-',
},
{
header: 'Aksi',
cell: (props) => {
@@ -392,10 +449,17 @@ const PurchaseTable = () => {
setIsDeleteLoading(true);
try {
await PurchaseApi.delete(selectedPurchase?.id as number);
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
const deleteResponse = await PurchaseApi.delete(
selectedPurchase?.id as number
);
if (isResponseSuccess(deleteResponse)) {
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
} else {
toast.error(deleteResponse?.message ?? 'Gagal menghapus data!');
}
} catch {
toast.error('Gagal menghapus data permintaan pembelian!');
}
@@ -403,34 +467,191 @@ const PurchaseTable = () => {
setIsDeleteLoading(false);
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('purchase-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
},
[updateFilter, setSearchValue]
[updateFilter]
);
const filterSubmitHandler = (values: PurchaseFilter) => {
updateFilter('po_date', values.poDate);
updateFilter('product_category_id', values.category.join(','));
updateFilter('approval_status', values.status.join(','));
setFilters({
po_date: values.poDate,
product_category_id: values.category.join(','),
product_category_name:
values.category_labels?.map((item) => item.label).join(',') || '',
approval_status: values.status.join(','),
supplier_id: values.supplier_id ? String(values.supplier_id) : '',
supplier_name: values.supplier_label || '',
area_id: values.area_id ? String(values.area_id) : '',
area_name: values.area_label || '',
location_id: values.location_id ? String(values.location_id) : '',
location_name: values.location_label || '',
project_flock_id: values.project_flock_id
? String(values.project_flock_id)
: '',
project_flock_name: values.project_flock_label || '',
project_flock_kandang_id: values.project_flock_kandang_id
? String(values.project_flock_kandang_id)
: '',
project_flock_kandang_name: values.project_flock_kandang_label || '',
});
};
const filterResetHandler = () => {
updateFilter('po_date', '');
updateFilter('product_category_id', '');
updateFilter('approval_status', '');
setFilters({
po_date: '',
product_category_id: '',
product_category_name: '',
approval_status: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
});
};
const purchaseFilterInitialValues = useMemo(() => {
const categoryIds = tableFilterState.product_category_id
? tableFilterState.product_category_id
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const categoryLabels = tableFilterState.product_category_name
? tableFilterState.product_category_name
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const approvalStatuses = tableFilterState.approval_status
? tableFilterState.approval_status
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return {
poDate: tableFilterState.po_date,
category: categoryIds.map((value, index) => ({
value: Number(value),
label: categoryLabels[index] || value,
})),
status: approvalStatuses.map((value) => ({
value,
label:
PURCHASE_ORDER_APPROVAL_LINE.find((item) => item.step_name === value)
?.step_name || value,
})),
supplier: tableFilterState.supplier_id
? ({
value: Number(tableFilterState.supplier_id),
label:
tableFilterState.supplier_name || tableFilterState.supplier_id,
} as OptionType<number>)
: null,
area: tableFilterState.area_id
? ({
value: Number(tableFilterState.area_id),
label: tableFilterState.area_name || tableFilterState.area_id,
} as OptionType<number>)
: null,
location: tableFilterState.location_id
? ({
value: Number(tableFilterState.location_id),
label:
tableFilterState.location_name || tableFilterState.location_id,
} as OptionType<number>)
: null,
project_flock: tableFilterState.project_flock_id
? ({
value: Number(tableFilterState.project_flock_id),
label:
tableFilterState.project_flock_name ||
tableFilterState.project_flock_id,
} as OptionType<number>)
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? ({
value: Number(tableFilterState.project_flock_kandang_id),
label:
tableFilterState.project_flock_kandang_name ||
tableFilterState.project_flock_kandang_id,
} as OptionType<number>)
: null,
};
}, [tableFilterState]);
const exportToExcel = useCallback(async () => {
setIsLoadingExportingToExcel(true);
try {
await PurchaseApi.exportToExcel(getTableFilterQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pembelian')
);
} finally {
setIsLoadingExportingToExcel(false);
}
}, [getTableFilterQueryString]);
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressStartDate(e.target.value);
}, []);
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressEndDate(e.target.value);
}, []);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await PurchaseApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
return (
<>
<div className='w-full'>
@@ -477,11 +698,73 @@ const PurchaseTable = () => {
'search',
'filter_by',
'sort_by',
'order_by',
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
]}
fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcel}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
</div>
@@ -529,7 +812,8 @@ const PurchaseTable = () => {
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
@@ -543,6 +827,7 @@ const PurchaseTable = () => {
<PurchaseFilterModal
ref={filterModal.ref}
initialValues={purchaseFilterInitialValues}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
@@ -562,6 +847,76 @@ const PurchaseTable = () => {
onClick: confirmationModalDeleteClickHandler,
}}
/>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
</>
);
};
@@ -55,7 +55,6 @@ const PurchaseRequestForm = ({
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [, setLocationSelectInputValue] = useState('');
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
[]
);
@@ -163,6 +162,7 @@ const PurchaseRequestForm = ({
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
setInputValue: setLocationSelectInputValue,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
@@ -26,6 +26,8 @@ import PurchaseOrderAcceptApprovalForm from '@/components/pages/purchase/form/or
import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import {
CreateAcceptApprovalRequestPayload,
CreateManagerApprovalRequestPayload,
@@ -96,6 +98,7 @@ const PurchaseOrderDetail = ({
const acceptRejectionModal = useModal();
const managerRejectionModal = useModal();
const editModal = useModal();
const editPoDateModal = useModal();
const penerimaanBarangModal = useModal();
const deleteModal = useModal();
@@ -105,6 +108,9 @@ const PurchaseOrderDetail = ({
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<PurchaseItem | null>(null);
const [, setApprovalNotes] = useState('');
const [managerApprovalNotes, setManagerApprovalNotes] = useState('');
const [managerApprovalPoDate, setManagerApprovalPoDate] = useState('');
const [editPoDate, setEditPoDate] = useState('');
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
@@ -212,6 +218,8 @@ const PurchaseOrderDetail = ({
break;
case 2:
setApprovalNotes('');
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.openModal();
break;
case 3:
@@ -414,17 +422,50 @@ const PurchaseOrderDetail = ({
deleteModal,
]);
const updatePoDateHandler = useCallback(async () => {
const purchaseRequestId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: initialValues?.id || 1;
if (!purchaseRequestId) {
toast.error('Purchase Request ID is required');
return;
}
const res = await PurchaseApi.updatePoDate(purchaseRequestId, {
po_date: editPoDate,
});
if (isResponseError(res)) {
toast.error(res.message || 'Gagal mengubah tanggal PO');
return;
}
toast.success('Tanggal PO berhasil diubah');
setEditPoDate('');
editPoDateModal.closeModal();
refetchData?.();
}, [
initialValues?.id,
searchParams,
editPoDate,
editPoDateModal,
refetchData,
]);
// ===== APPROVAL/REJECTION HANDLERS =====
const managerApprovalHandler = async (notes: string) => {
const managerApprovalHandler = async () => {
const payload: CreateManagerApprovalRequestPayload = {
action: 'APPROVED',
notes: notes || null,
notes: managerApprovalNotes || null,
po_date: managerApprovalPoDate || null,
};
await createManagerApprovalHandler(payload);
await refreshApprovals();
await refetchData?.();
setApprovalNotes('');
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
};
@@ -829,6 +870,41 @@ const PurchaseOrderDetail = ({
</div>
</div>
</div>
{purchaseData.po_date &&
!purchaseData.po_date.startsWith('0001') && (
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Tanggal PO
</span>
<div className='ml-3 flex items-center gap-1'>
<span className='text-gray-900'>
: {formatDate(purchaseData.po_date, 'DD MMM YYYY')}
</span>
<RequirePermission permissions='lti.purchase.update'>
<Button
type='button'
variant='ghost'
color='warning'
className='p-1 min-h-0 h-auto'
onClick={() => {
setEditPoDate(
formatDate(purchaseData.po_date, 'YYYY-MM-DD')
);
editPoDateModal.openModal();
}}
>
<Icon
icon='material-symbols:edit-outline'
width={14}
height={14}
/>
</Button>
</RequirePermission>
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -1016,27 +1092,79 @@ const PurchaseOrderDetail = ({
</div>
</Card>
{/* Confirmation Modal with Notes */}
<ConfirmationModalWithNotes
{/* Manager Approval Modal */}
<Modal
ref={confirmationModalWithNotes.ref}
type='success'
text='Apakah Anda yakin ingin melanjutkan approval ini?'
placeholder='(Opsional) Tambahkan catatan untuk approval ini...'
rows={4}
closeOnBackdrop
primaryButton={{
text: 'Ya, Lanjutkan',
color: 'success',
onClick: managerApprovalHandler,
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
secondaryButton={{
text: 'Batal',
onClick: () => {
setApprovalNotes('');
confirmationModalWithNotes.closeModal();
},
}}
/>
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Konfirmasi Approval Manager
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<p className='text-sm text-base-content/70'>
Apakah Anda yakin ingin melanjutkan approval ini?
</p>
<DateInput
name='manager_approval_po_date'
label='Tanggal PO'
value={managerApprovalPoDate}
onChange={(e) => setManagerApprovalPoDate(e.target.value)}
isNestedModal
/>
<TextArea
name='manager_approval_notes'
label='Catatan (Opsional)'
placeholder='Tambahkan catatan untuk approval ini...'
value={managerApprovalNotes}
onChange={(e) => setManagerApprovalNotes(e.target.value)}
rows={4}
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={managerApprovalHandler}
className='px-3 py-2.5'
>
Ya, Lanjutkan
</Button>
</div>
</div>
</Modal>
{/* Staff Approval Modal */}
<Modal
@@ -1112,6 +1240,66 @@ const PurchaseOrderDetail = ({
/>
</Modal>
{/* Edit PO Date Modal */}
<Modal
ref={editPoDateModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-sm rounded-lg p-0',
}}
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Edit Tanggal PO
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='edit_po_date'
label='Tanggal PO'
value={editPoDate}
onChange={(e) => setEditPoDate(e.target.value)}
isNestedModal
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='primary'
onClick={updatePoDateHandler}
className='px-3 py-2.5'
disabled={!editPoDate}
>
Simpan
</Button>
</div>
</div>
</Modal>
{/* Staff Rejection Modal */}
<ConfirmationModalWithNotes
ref={staffRejectionModal.ref}
@@ -4,7 +4,8 @@ import { useState } from 'react';
import Tabs from '@/components/Tabs';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ReportExpenseTab from './tab/ReportExpenseTab';
import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab';
import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab';
const ReportExpenseTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
@@ -16,6 +17,11 @@ const ReportExpenseTabs = () => {
label: 'Laporan Biaya Operasional',
content: <ReportExpenseTab tabId={'1'} />,
},
{
id: '2',
label: 'Laporan Depresiasi',
content: <ReportDepreciationTab tabId={'2'} />,
},
];
return (
@@ -1,27 +1,26 @@
import React from 'react';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ColumnDef } from '@tanstack/react-table';
type ReportExpenseColumn =
| ColumnDef<ReportExpense>
type ReportSkeletonColumn<TData extends object> =
| ColumnDef<TData>
| {
header: string;
columns: Array<{
header: string;
accessorKey?: string;
cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
cell?: (props: { row: { original: TData } }) => React.ReactNode;
}>;
};
const ReportExpenseSkeleton = ({
const ReportExpenseSkeleton = <TData extends object>({
columns,
icon,
title,
subtitle,
}: {
columns: ReportExpenseColumn[];
columns: ReportSkeletonColumn<TData>[];
icon: React.ReactNode;
title: string;
subtitle: string;
@@ -0,0 +1,276 @@
'use client';
import { RefObject, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import * as yup from 'yup';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { AreaApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock';
export type ReportDepreciationFilterValues = {
area_id: string | null;
location_id: string | null;
project_flock_id: string | null;
period: string | null;
};
export const ReportDepreciationFilterSchema = yup.object({
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
project_flock_id: yup.string().nullable(),
period: yup.string().nullable().required('Periode wajib dipilih'),
}) as yup.ObjectSchema<ReportDepreciationFilterValues>;
interface ReportDepreciationFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: ReportDepreciationFilterValues;
onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void;
onReset?: () => void;
}
const defaultInitialValues: ReportDepreciationFilterValues = {
area_id: null,
location_id: null,
project_flock_id: null,
period: null,
};
const ReportDepreciationFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: ReportDepreciationFilterModalProps) => {
const [selectedAreaId, setSelectedAreaId] = useState<string | undefined>(
initialValues?.area_id || undefined
);
const [selectedLocationId, setSelectedLocationId] = useState<
string | undefined
>(initialValues?.location_id || undefined);
useEffect(() => {
setSelectedAreaId(initialValues?.area_id || undefined);
setSelectedLocationId(initialValues?.location_id || undefined);
}, [initialValues?.area_id, initialValues?.location_id]);
const closeModalHandler = () => {
ref.current?.close();
};
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedAreaId || '',
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<ReportDepreciationFilterValues>({
initialValues: initialValues || defaultInitialValues,
enableReinitialize: true,
validationSchema: ReportDepreciationFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
onReset: (_) => {
onReset?.();
closeModalHandler();
},
});
const areaValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const locationValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const projectFlockValue = useMemo(() => {
if (!formik.values.project_flock_id) return null;
return (
projectFlockOptions.find(
(opt) => String(opt.value) === formik.values.project_flock_id
) || null
);
}, [formik.values.project_flock_id, projectFlockOptions]);
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
const areaId = val && !Array.isArray(val) ? String(val.value) : null;
setSelectedAreaId(areaId || undefined);
formik.setFieldValue('area_id', areaId);
formik.setFieldValue('location_id', null);
formik.setFieldValue('project_flock_id', null);
setSelectedLocationId(undefined);
};
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId = val && !Array.isArray(val) ? String(val.value) : null;
setSelectedLocationId(locationId || undefined);
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('project_flock_id', null);
};
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
const projectFlockId =
val && !Array.isArray(val) ? String(val.value) : null;
formik.setFieldValue('project_flock_id', projectFlockId);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full flex flex-col'
>
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={areaValue}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationValue}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={projectFlockValue}
onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isLoading={isLoadingProjectFlockOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<DateInput
label='Periode'
name='period'
placeholder='Pilih Periode'
value={formik.values.period || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.period && !!formik.errors.period}
errorMessage={formik.errors.period}
required
isNestedModal
/>
</div>
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default ReportDepreciationFilterModal;
@@ -0,0 +1,255 @@
'use client';
import React, { useEffect, useMemo } from 'react';
import useSWR from 'swr';
import { ColumnDef } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
import { useModal } from '@/components/Modal';
import ReportDepreciationFilterModal from '@/components/pages/report/expense/tab/ReportDepreciationFilterModal';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import { ReportDepreciation } from '@/types/api/report/report-expense';
import { DepreciationReportApi } from '@/services/api/report/expense-report';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
interface ReportDepreciationTabProps {
tabId: string;
}
const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter({
initial: {
area_id: '',
location_id: '',
project_flock_id: '',
period: formatDate(Date.now(), 'YYYY-MM-DD'),
},
paramMap: {
pageSize: 'limit',
area_id: 'area_id',
location_id: 'location_id',
project_flock_id: 'project_flock_id',
period: 'period',
},
});
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
useSWR(
`${DepreciationReportApi.basePath}${getTableFilterQueryString()}`,
DepreciationReportApi.getAllFetcher
);
const depreciations = isResponseSuccess(depreciationsResponse)
? depreciationsResponse.data
: [];
const filterModal = useModal();
const { ref: filterModalRef } = filterModal;
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const depreciationKandangColumns: ColumnDef<
ReportDepreciation['components']['kandang'][0]
>[] = [
{
accessorKey: 'kandang_name',
header: 'Kandang',
},
{
accessorKey: 'house_type',
header: 'Tipe Kandang',
cell: ({ row }) => row.original.house_type.toUpperCase(),
},
{
accessorKey: 'depreciation_percent',
header: 'Persentase Depresiasi',
cell: ({ row }) => row.original.depreciation_percent + '%',
},
{
accessorKey: 'depreciation_value',
header: 'Nilai Depresiasi',
cell: ({ row }) => formatCurrency(row.original.depreciation_value),
},
{
accessorKey: 'depreciation_source',
header: 'Asal Depresiasi',
cell: ({ row }) => row.original.depreciation_source.toUpperCase(),
},
{
accessorKey: 'cutover_date',
header: 'Tanggal Cutover',
cell: ({ row }) => formatDate(row.original.cutover_date, 'DD MMM YYYY'),
},
{
accessorKey: 'origin_date',
header: 'Tanggal Origin',
cell: ({ row }) => formatDate(row.original.origin_date, 'DD MMM YYYY'),
},
];
const tabActionsElement = useMemo(
() => (
<div className='flex flex-row gap-3'>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize']}
onClick={() => filterModal.openModal()}
variant='outline'
className='px-3 py-2.5'
/>
</div>
),
[tableFilterState]
);
useEffect(() => {
setTabActions(tabId, tabActionsElement);
}, [setTabActions, tabActionsElement, tabId]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions, tabId]);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoadingDepreciations && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoadingDepreciations && depreciations.length === 0 && (
<ReportExpenseSkeleton
columns={depreciationKandangColumns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoadingDepreciations && depreciations.length > 0 && (
<>
{depreciations.map((depreciationItem, idx) => (
<Card
key={idx}
title={depreciationItem.farm_name}
subtitle={`Period: ${formatDate(depreciationItem.period, 'DD MMM YYYY')} | Depresiasi Efektif: ${formatNumber(depreciationItem.depreciation_percent_effective, 'en-US', 0, 10)}% | Nilai Depresiasi: ${formatCurrency(depreciationItem.depreciation_value)} | Total Pullet Cost: ${formatCurrency(depreciationItem.pullet_cost_day_n_total, 'IDR', 'id-ID', 0, 10)}`}
className={{
wrapper: 'w-full rounded-lg border-none',
body: 'p-0',
title:
'px-2 py-1.5 font-normal text-sm bg-primary text-white',
subtitle:
'px-2 pb-1.5 bg-primary text-white text-xs font-normal',
collapsible: 'rounded-lg',
}}
variant='bordered'
collapsible={true}
>
<Table
data={depreciationItem.components.kandang}
columns={depreciationKandangColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(depreciationsResponse)
? depreciationsResponse?.meta?.page
: 0
}
totalItems={
isResponseSuccess(depreciationsResponse)
? depreciationsResponse?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoadingDepreciations}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName:
'overflow-x-auto rounded-tr-none rounded-tl-none',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
</Card>
))}
<Pagination
totalItems={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.total_results ?? 0)
: 0
}
itemsPerPage={tableFilterState.pageSize}
currentPage={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.page ?? 0)
: 0
}
onPrevPage={() => setPage(tableFilterState.page - 1)}
onNextPage={() => setPage(tableFilterState.page + 1)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</>
)}
</div>
<ReportDepreciationFilterModal
ref={filterModalRef}
initialValues={tableFilterState}
onReset={resetFilter}
onSubmit={(values) => {
updateFilter('area_id', values.area_id ?? '');
updateFilter('location_id', values.location_id ?? '');
updateFilter('project_flock_id', values.project_flock_id ?? '');
updateFilter(
'period',
values.period ? formatDate(values.period, 'YYYY-MM-DD') : ''
);
}}
/>
</>
);
};
export default ReportDepreciationTab;
@@ -23,8 +23,8 @@ import RealizationStatusBadge from '@/components/pages/expense/RealizationStatus
import Table from '@/components/Table';
import { formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ReportExpenseApi } from '@/services/api/report';
import { isResponseSuccess } from '@/lib/api-helper';
import { ReportExpenseApi } from '@/services/api/report/expense-report';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import Modal, { useModal } from '@/components/Modal';
import Pagination from '@/components/Pagination';
@@ -126,8 +126,49 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
});
handleFilterModalOpenRef.current = () => {
const restoredLocation = filterParams.location_id
? locationOptions.find(
(opt) => String(opt.value) === filterParams.location_id
) || {
value: filterParams.location_id,
label: filterParams.location_id,
}
: null;
const restoredSupplier = filterParams.supplier_id
? supplierOptions.find(
(opt) => String(opt.value) === filterParams.supplier_id
) || {
value: filterParams.supplier_id,
label: filterParams.supplier_id,
}
: null;
const restoredKandang = filterParams.kandang_id
? projectFlockKandangOptions.find(
(opt) => String(opt.value) === filterParams.kandang_id
) || { value: filterParams.kandang_id, label: filterParams.kandang_id }
: null;
const restoredNonstock = filterParams.nonstock_id
? nonstockOptions.find(
(opt) => String(opt.value) === filterParams.nonstock_id
) || {
value: filterParams.nonstock_id,
label: filterParams.nonstock_id,
}
: null;
const restoredCategory = filterParams.category
? categoryOptions.find((opt) => opt.value === filterParams.category) ||
null
: null;
formik.setValues({
location_id: restoredLocation,
supplier_id: restoredSupplier,
kandang_id: restoredKandang,
nonstock_id: restoredNonstock,
realization_date: filterParams.realization_date || null,
category: restoredCategory,
});
filterModal.openModal();
formik.validateForm();
};
// ===== OPTIONS =====
@@ -189,26 +230,47 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
[formik.values.category]
);
const buildReportExpenseQueryString = useCallback(
(extraParams?: Record<string, string>) => {
const params = new URLSearchParams();
if (filterParams.location_id) {
params.append('location_id', filterParams.location_id);
}
if (filterParams.supplier_id) {
params.append('supplier_id', filterParams.supplier_id);
}
if (filterParams.kandang_id) {
params.append('project_flock_kandang_id', filterParams.kandang_id);
}
if (filterParams.nonstock_id) {
params.append('nonstock_id', filterParams.nonstock_id);
}
if (filterParams.realization_date) {
params.append('realization_date', filterParams.realization_date);
}
if (filterParams.category) {
params.append('category', filterParams.category);
}
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
params.set(key, value);
});
return params.toString();
},
[filterParams]
);
// ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR(
() => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('project_flock_kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category)
params.append('category', filterParams.category);
params.append('page', String(page));
params.append('limit', String(pageSize));
const queryString = buildReportExpenseQueryString({
page: String(page),
limit: String(pageSize),
});
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
return [`${ReportExpenseApi.basePath}?${queryString}`];
},
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
);
@@ -233,47 +295,31 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null
> => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category) params.append('category', filterParams.category);
params.append('limit', '100');
params.append('page', '1');
const queryString = buildReportExpenseQueryString({
page: '1',
limit: '100',
});
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
`${ReportExpenseApi.basePath}?${params.toString()}`
`${ReportExpenseApi.basePath}?${queryString}`
);
return isResponseSuccess(response) ? response.data : null;
}, [filterParams]);
}, [buildReportExpenseQueryString]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await reportExpenseExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateReportExpenseExcel(allDataForExport);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
);
} finally {
setIsExcelExportLoading(false);
}
}, [reportExpenseExport]);
}, [buildReportExpenseQueryString]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -38,6 +38,7 @@ import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Pagination from '@/components/Pagination';
interface CustomerPaymentTabProps {
tabId: string;
@@ -58,7 +59,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
@@ -117,8 +118,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
});
handleFilterModalOpenRef.current = () => {
formik.setValues({
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
customer_ids: filterParams.customer_ids || null,
filter_by: filterParams.filter_by || null,
});
filterModal.openModal();
formik.validateForm();
};
const getPaymentStatusBadgeColor = (notes: string): Color => {
@@ -249,6 +255,14 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
[customerPayment]
);
const meta = useMemo(
() =>
isResponseSuccess(customerPayment) && customerPayment.meta
? customerPayment.meta
: null,
[customerPayment]
);
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
@@ -717,6 +731,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
/>
)}
{!isLoading && data.length > 0 && meta && (
<div className='w-full ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
{!isLoading &&
data.length > 0 &&
data.map((customerReport) => {
@@ -811,6 +846,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
</Card>
);
})}
{!isLoading && data.length > 0 && meta && (
<div className='mt-5 px-3'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</div>
{/* Filter Modal */}
@@ -1,6 +1,7 @@
import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import Pagination from '@/components/Pagination';
import DateInput from '@/components/input/DateInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
@@ -78,6 +79,10 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
start_date: undefined,
@@ -128,7 +133,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal();
// setIsSubmitted(true);
setCurrentPage(1);
},
onReset: () => {
setFilterParams({
@@ -137,14 +142,30 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
supplier_ids: undefined,
filter_by: undefined,
});
// setIsSubmitted(false);
setCurrentPage(1);
filterModal.closeModal();
},
});
handleFilterModalOpenRef.current = () => {
const restoredFilterBy =
dataTypeOptions.find((opt) => opt.value === filterParams.filter_by) ||
null;
const supplierIdList = filterParams.supplier_ids
? filterParams.supplier_ids.split(',')
: [];
const restoredSupplierIds = supplierOptions.filter((opt) =>
supplierIdList.includes(String(opt.value))
);
formik.setValues({
startDate: filterParams.start_date || null,
endDate: filterParams.end_date || null,
supplierIds: restoredSupplierIds.length > 0 ? restoredSupplierIds : null,
filterBy: restoredFilterBy,
});
filterModal.openModal();
formik.validateForm();
};
// ===== DATA FETCHING =====
@@ -155,6 +176,8 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
filter_by: filterParams.filter_by,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
return ['debt-supplier-report', params];
@@ -164,7 +187,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date
params.end_date,
params.page,
params.limit
)
);
@@ -176,6 +201,14 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
[debtSupplier]
);
const meta = useMemo(
() =>
isResponseSuccess(debtSupplier) && debtSupplier.meta
? debtSupplier.meta
: null,
[debtSupplier]
);
// ===== EXPORT DATA FETCHER =====
const debtSupplierExport = useCallback(async (): Promise<
DebtSupplier[] | null
@@ -630,6 +663,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
/>
)}
{!isLoading && data.length > 0 && meta && (
<div className='w-full ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
{!isLoading &&
data.length > 0 &&
data.map((supplierReport) => {
@@ -717,6 +771,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</Card>
);
})}
{!isLoading && data.length > 0 && meta && (
<div className='mt-5 px-3'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</div>
{/* Filter Modal */}
@@ -156,8 +156,17 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
});
handleFilterModalOpenRef.current = () => {
formik.setValues({
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
area_ids: filterParams.area_id || null,
supplier_ids: filterParams.supplier_id || null,
product_ids: filterParams.product_id || null,
product_category_ids: filterParams.product_category_id || null,
filter_by: filterParams.filter_by || null,
sort_by: filterParams.sort_by || null,
});
filterModal.openModal();
formik.validateForm();
};
const { setFieldValue } = formik;
@@ -1,6 +1,8 @@
import * as yup from 'yup';
export type DailyMarketingReportFilterType = {
page?: number;
pageSize?: number;
search: string | null;
area_id: string | null;
location_id: string | null;
@@ -14,6 +16,8 @@ export type DailyMarketingReportFilterType = {
};
export const DailyMarketingReportFilterSchema = yup.object({
page: yup.number().nullable(),
pageSize: yup.number().nullable(),
search: yup.string().nullable(),
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
@@ -1,6 +1,8 @@
import * as yup from 'yup';
export type HppPerKandangFilterType = {
page?: number;
pageSize?: number;
area_id: string | null;
location_id: string | null;
kandang_id: string | null;
@@ -12,6 +14,8 @@ export type HppPerKandangFilterType = {
};
export const HppPerKandangFilterSchema = yup.object({
page: yup.number().nullable(),
pageSize: yup.number().nullable(),
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
kandang_id: yup.string().nullable(),
@@ -17,16 +17,10 @@ import {
formatVechicleNumber,
formatTitleCase,
} from '@/lib/helper';
import {
DailyMarketingRow,
DailyMarketingReportResponse,
} from '@/types/api/report/marketing';
import { DailyMarketingRow } from '@/types/api/report/marketing';
import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
@@ -39,8 +33,6 @@ import Modal, { useModal } from '@/components/Modal';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
import { useEffect as useEffectHook } from 'react';
import { httpClient } from '@/services/http/client';
import { isResponseError } from '@/lib/api-helper';
import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
@@ -53,6 +45,8 @@ interface DailyMarketingTabProps {
}
interface FilterParams {
page?: number;
pageSize?: number;
area_id?: string;
location_id?: string;
warehouse_id?: string;
@@ -116,6 +110,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
// ===== FORMIK SETUP =====
const formik = useFormik<DailyMarketingReportFilterType>({
initialValues: {
page: 1,
pageSize: 10,
search: null,
area_id: null,
location_id: null,
@@ -130,6 +126,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
validationSchema: DailyMarketingReportFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
page: values.page || undefined,
pageSize: values.pageSize || undefined,
area_id: values.area_id || undefined,
location_id: values.location_id || undefined,
warehouse_id: values.warehouse_id || undefined,
@@ -150,8 +148,21 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
});
handleFilterModalOpenRef.current = () => {
formik.setValues({
page: formik.values.page,
pageSize: formik.values.pageSize,
search: formik.values.search,
area_id: filterParams.area_id || null,
location_id: filterParams.location_id || null,
warehouse_id: filterParams.warehouse_id || null,
customer_id: filterParams.customer_id || null,
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
filter_by: filterParams.filter_by || null,
marketing_type: filterParams.marketing_type || null,
sort_by: filterParams.sort_by || null,
});
filterModal.openModal();
formik.validateForm();
};
// ===== SEARCH CHANGE HANDLER =====
@@ -222,6 +233,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.page) params.set('page', String(filterParams.page));
if (filterParams.pageSize)
params.set('limit', String(filterParams.pageSize));
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
@@ -262,67 +276,30 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
[dailyMarketings]
);
// ===== EXPORT DATA FETCHER =====
const dailyMarketingsExport = useCallback(async (): Promise<
DailyMarketingRow[] | null
> => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
params.set('limit', '9999999');
const queryString = `?${params.toString()}`;
try {
const response = await httpClient<DailyMarketingReportResponse>(
`${MarketingReportApi.basePath}${queryString}`
);
if (isResponseError(response)) {
return null;
}
return response.data || [];
} catch {
return null;
}
}, [filterParams, searchValue]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await dailyMarketingsExport();
const params = new URLSearchParams();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
const period =
filterParams.start_date && filterParams.end_date
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
: undefined;
await generateDailyMarketingExcel({
data: allDataForExport,
summaryTotal: summaryTotal,
period: period,
});
await MarketingReportApi.exportDailyMarketingToExcel(params.toString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
@@ -330,34 +307,39 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, dailyMarketingsExport, summaryTotal]);
}, [filterParams, searchValue]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await dailyMarketingsExport();
const params = new URLSearchParams();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
const dailyMarketingReportPdfBlob = await pdf(
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
).toBlob();
await MarketingReportApi.exportDailyMarketingToPDF(params.toString());
const dailyMarketingReportPdfUrl = URL.createObjectURL(
dailyMarketingReportPdfBlob
);
window.open(dailyMarketingReportPdfUrl, '_blank');
toast.success('PDF berhasil dibuat.');
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [dailyMarketingsExport, summaryTotal]);
}, [filterParams, searchValue]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
@@ -572,7 +554,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'qty',
cell: (props) => formatNumber(props.row.original.qty),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_qty
? formatNumber(summaryTotal.total_qty)
: '-'}
@@ -585,7 +567,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'average_weight_kg',
cell: (props) => formatNumber(props.row.original.average_weight_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.average_weight_kg
? formatNumber(summaryTotal.average_weight_kg)
: '-'}
@@ -598,7 +580,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'total_weight_kg',
cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_weight_kg
? formatNumber(summaryTotal.total_weight_kg)
: '-'}
@@ -611,9 +593,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'sales_price_per_kg',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.average_sales_price
? formatNumber(summaryTotal.average_sales_price)
? formatCurrency(summaryTotal.average_sales_price)
: '-'}
</div>
),
@@ -624,7 +606,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'hpp_price_per_kg',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_hpp_price_per_kg
? formatCurrency(summaryTotal.total_hpp_price_per_kg)
: '-'}
@@ -637,7 +619,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'sales_amount',
cell: (props) => formatCurrency(props.row.original.sales_amount),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_sales_amount
? formatCurrency(summaryTotal.total_sales_amount)
: '-'}
@@ -688,6 +670,27 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
<Table
data={data}
columns={getTableColumns()}
pageSize={filterParams.pageSize}
page={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.page
: 0
}
totalItems={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.total_results
: 0
}
onPageChange={(newPage) =>
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
}
onPageSizeChange={(newPageSize) =>
setFilterParams((prevVal) => ({
...prevVal,
pageSize: newPageSize,
}))
}
isLoading={isLoading}
renderFooter={data.length > 0}
className={{
containerClassName: 'w-full mb-0!',
@@ -40,6 +40,8 @@ interface HppPerKandangTabProps {
}
interface FilterParams {
page?: number;
pageSize?: number;
area_id?: string;
location_id?: string;
kandang_id?: string;
@@ -108,6 +110,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
// ===== FORMIK SETUP =====
const formik = useFormik<HppPerKandangFilterType>({
initialValues: {
page: 1,
pageSize: 10,
area_id: null,
location_id: null,
kandang_id: null,
@@ -120,6 +124,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
validationSchema: HppPerKandangFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
page: values.page || undefined,
pageSize: values.pageSize || undefined,
area_id: values.area_id || undefined,
location_id: values.location_id || undefined,
kandang_id: values.kandang_id || undefined,
@@ -146,8 +152,19 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
});
handleFilterModalOpenRef.current = () => {
formik.setValues({
page: formik.values.page,
pageSize: formik.values.pageSize,
area_id: filterParams.area_id || null,
location_id: filterParams.location_id || null,
kandang_id: filterParams.kandang_id || null,
weight_min: filterParams.weight_min || null,
weight_max: filterParams.weight_max || null,
period: filterParams.period || null,
sort_by: filterParams.sort_by || null,
show_unrecorded: filterParams.show_unrecorded ?? false,
});
filterModal.openModal();
formik.validateForm();
};
// ===== WEIGHT CHANGE HANDLERS =====
@@ -257,6 +274,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
period: filterParams.period,
sort_by: filterParams.sort_by,
show_unrecorded: filterParams.show_unrecorded,
page: filterParams.page,
pageSize: filterParams.pageSize,
};
return ['hpp-per-kandang-report', params];
@@ -271,7 +290,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
params.show_unrecorded,
params.page,
params.pageSize
)
);
@@ -321,7 +342,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
params.show_unrecorded,
params.page,
params.limit
);
return isResponseSuccess(response) ? response.data : null;
@@ -466,6 +489,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
<div className='flex flex-row gap-3'>
<ButtonFilter
values={filterParams}
excludeFields={['page', 'pageSize']}
onClick={() => handleFilterModalOpenRef.current()}
variant='outline'
className='px-3 py-2.5'
@@ -845,6 +869,25 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
<Table
data={data}
columns={getTableColumns()}
pageSize={filterParams.pageSize}
page={
isResponseSuccess(hppPerKandang) ? hppPerKandang?.meta?.page : 0
}
totalItems={
isResponseSuccess(hppPerKandang)
? hppPerKandang?.meta?.total_results
: 0
}
onPageChange={(newPage) =>
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
}
onPageSizeChange={(newPageSize) =>
setFilterParams((prevVal) => ({
...prevVal,
pageSize: newPageSize,
}))
}
isLoading={isLoading}
renderFooter={data.length > 0}
renderCustomRow={renderCustomRow}
className={{
@@ -263,8 +263,43 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
});
handleFilterModalOpenRef.current = () => {
const restoredAreaId = filterParams.area_id
? areaOptions.find(
(opt) => String(opt.value) === filterParams.area_id
) || { value: filterParams.area_id, label: filterParams.area_id }
: null;
const restoredLocationId = filterParams.location_id
? locationOptions.find(
(opt) => String(opt.value) === filterParams.location_id
) || {
value: filterParams.location_id,
label: filterParams.location_id,
}
: null;
const restoredProjectFlockId = filterParams.project_flock_id
? projectFlockOptions.find(
(opt) => String(opt.value) === filterParams.project_flock_id
) || {
value: filterParams.project_flock_id,
label: filterParams.project_flock_id,
}
: null;
const restoredKandangId = filterParams.project_flock_kandang_id
? projectFlockKandangOptions.find(
(opt) => String(opt.value) === filterParams.project_flock_kandang_id
) || {
value: filterParams.project_flock_kandang_id,
label: filterParams.project_flock_kandang_id,
}
: null;
formik.setValues({
area_id: restoredAreaId,
location_id: restoredLocationId,
project_flock_id: restoredProjectFlockId,
kandang_id: restoredKandangId,
});
filterModal.openModal();
formik.validateForm();
};
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
+11 -1
View File
@@ -197,6 +197,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
icon: 'heroicons-outline:folder',
permission: [
'lti.inventory.product_stock.list',
'lti.inventory.stock_log.list',
'lti.inventory.product_warehouses.list',
'lti.inventory.transfer.list',
],
@@ -204,7 +205,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
{
text: 'Stok Produk',
link: '/inventory/product',
permission: ['lti.inventory.product_stock.list'],
permission: [
'lti.inventory.product_stock.list',
'lti.inventory.stock_log.list',
],
},
{
text: 'Penyesuaian Stok',
@@ -236,6 +240,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
'lti.master.uoms.list',
'lti.master.warehouses.list',
'lti.master.production_standards.list',
'lti.system_settings.update',
],
submenu: [
{
@@ -303,6 +308,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/master-data/production-standard',
permission: ['lti.master.production_standards.list'],
},
{
text: 'Konfigurasi Sistem',
link: '/master-data/system-config',
permission: ['lti.system_settings.update'],
},
],
},
] as const;
+2
View File
@@ -218,4 +218,6 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/master-data/production-standard/detail/edit/': [
'lti.master.production_standards.update',
],
'/master-data/system-config/': ['lti.system_settings.update'],
};
File diff suppressed because it is too large Load Diff
@@ -89,7 +89,10 @@ export function Dashboard() {
options: kandangOptions,
loadMore: loadMoreKandang,
isLoadingMore: isLoadingMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
order_by: 'asc',
sort_by: 'name',
});
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
@@ -40,11 +40,12 @@ import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import Table from '@/components/Table';
import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist';
import { cn } from '@/lib/helper';
import { ColumnDef } from '@tanstack/react-table';
import { ColumnDef, Row } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import RequirePermission from '@/components/helper/RequirePermission';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
import CheckboxInput from '@/components/input/CheckboxInput';
const STATUS_OPTIONS = [
{ value: 'ALL', label: 'Semua Status' },
@@ -59,6 +60,7 @@ const CATEGORY_LABELS: { [key: string]: string } = {
pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close',
empty_kandang: 'Kandang Kosong',
};
export function ListDailyChecklistContent() {
@@ -87,6 +89,9 @@ export function ListDailyChecklistContent() {
date_from: 'date_from',
date_to: 'date_to',
},
persist: true,
storeName: 'list-daily-checklist-content-table',
});
const {
@@ -105,7 +110,10 @@ export function ListDailyChecklistContent() {
options: kandangOptions,
isLoadingMore: isLoadingMoreKandang,
loadMore: loadMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
order_by: 'asc',
sort_by: 'name',
});
const checklistList = isResponseSuccess(checklistListRes)
? checklistListRes.data || []
@@ -122,12 +130,29 @@ export function ListDailyChecklistContent() {
// Modals
const [showApproveModal, setShowApproveModal] = useState(false);
const [showBulkApproveModal, setShowBulkApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [showBulkRejectModal, setShowBulkRejectModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<DailyChecklist | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection);
const selectedRowItems = selectedRowIds.map((itemId) =>
checklistList.find((item) => item.id === parseInt(itemId))
);
const tableEnableRowSelectionHandler: (
row: Row<DailyChecklist>
) => boolean = (row) => {
return (
row.original.status !== 'APPROVED' && row.original.status !== 'REJECTED'
);
};
const handleDetail = (item: DailyChecklist) => {
router.push(
`/daily-checklist/list-daily-checklist/detail?checklistId=${item.id}`
@@ -135,13 +160,7 @@ export function ListDailyChecklistContent() {
};
const handleEdit = (item: DailyChecklist) => {
const formattedDate = new Date(item.date).toISOString().split('T')[0];
const kandangId = item.kandang?.id ?? '';
const category = item.category;
router.push(
`/daily-checklist/daily-checklist?date=${formattedDate}&kandang_id=${kandangId}&category=${category}`
);
router.push(`/daily-checklist/daily-checklist?checklistId=${item.id}`);
};
const handleApprove = (item: DailyChecklist) => {
@@ -149,21 +168,22 @@ export function ListDailyChecklistContent() {
setShowApproveModal(true);
};
const handleBulkApprove = () => {
setShowBulkApproveModal(true);
};
const handleReject = (item: DailyChecklist) => {
setSelectedItem(item);
setRejectReason('');
setShowRejectModal(true);
};
const handleDelete = (item: DailyChecklist) => {
// ✅ VALIDATION: Only DRAFT can be deleted
if (item.status !== 'DRAFT') {
toast.error('Hanya checklist dengan status DRAFT yang bisa dihapus', {
description: `Status saat ini: ${item.status}`,
});
return;
}
const handleBulkReject = () => {
setRejectReason('');
setShowBulkRejectModal(true);
};
const handleDelete = (item: DailyChecklist) => {
setSelectedItem(item);
setShowDeleteModal(true);
};
@@ -195,6 +215,31 @@ export function ListDailyChecklistContent() {
}
};
const confirmBulkApprove = async () => {
if (!selectedRowIds.length) return;
try {
setActionLoading(true);
const approveRes = await DailyChecklistApi.bulkApprove(selectedRowIds);
if (isResponseError(approveRes)) {
toast.error('Gagal approve checklist: ' + approveRes.message);
return;
}
refreshChecklistList();
toast.success('Checklist berhasil di-approve');
setShowBulkApproveModal(false);
setRowSelection({});
} catch (error) {
console.error('Error approving checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const confirmReject = async () => {
if (!selectedItem) return;
@@ -229,6 +274,40 @@ export function ListDailyChecklistContent() {
}
};
const confirmBulkReject = async () => {
if (!selectedRowIds.length) return;
if (!rejectReason.trim()) {
toast.error('Alasan reject harus diisi');
return;
}
try {
setActionLoading(true);
const rejectRes = await DailyChecklistApi.bulkReject(
selectedRowIds,
rejectReason
);
if (isResponseError(rejectRes)) {
toast.error('Gagal reject checklist: ' + rejectRes.message);
return;
}
refreshChecklistList();
toast.success('Checklist berhasil di-reject');
setShowBulkRejectModal(false);
setRowSelection({});
setRejectReason('');
} catch (error) {
console.error('Error rejecting checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const confirmDelete = async () => {
if (!selectedItem) return;
@@ -325,6 +404,37 @@ export function ListDailyChecklistContent() {
};
const checklistListColumns: ColumnDef<DailyChecklist>[] = [
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() ||
row.original.status === 'APPROVED' ||
row.original.status === 'REJECTED';
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={isCheckboxDisabled}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{
accessorKey: 'date',
header: 'Tanggal',
@@ -437,19 +547,17 @@ export function ListDailyChecklistContent() {
</RequirePermission>
)}
{row.original.status === 'DRAFT' && (
<RequirePermission permissions='lti.daily_checklist.create'>
<Button
size='sm'
variant='destructive'
onClick={() => handleDelete(row.original)}
className='bg-red-600 hover:bg-red-700 text-white'
>
<Trash2 className='w-4 h-4 mr-1' />
Hapus
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.daily_checklist.create'>
<Button
size='sm'
variant='destructive'
onClick={() => handleDelete(row.original)}
className='bg-red-600 hover:bg-red-700 text-white'
>
<Trash2 className='w-4 h-4 mr-1' />
Hapus
</Button>
</RequirePermission>
</div>
),
},
@@ -459,13 +567,39 @@ export function ListDailyChecklistContent() {
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title */}
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
List Daily Checklist
</h1>
<p className='text-sm text-gray-600 mt-1'>
Daftar semua checklist harian
</p>
<div className='mb-6 flex flex-row justify-between items-center gap-3'>
<div>
<h1 className='text-2xl font-semibold text-gray-900'>
List Daily Checklist
</h1>
<p className='text-sm text-gray-600 mt-1'>
Daftar semua checklist harian
</p>
</div>
<RequirePermission permissions='lti.daily_checklist.create'>
{selectedRowIds.length > 0 && (
<div className='flex flex-row items-center gap-3'>
<Button
size='sm'
onClick={handleBulkApprove}
className='bg-green-600 hover:bg-green-700 text-white'
>
<CheckCircle className='w-4 h-4 mr-1' />
Bulk Approve {`(${selectedRowIds.length}) item`}
</Button>
<Button
size='sm'
variant='destructive'
onClick={handleBulkReject}
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-1' />
Bulk Reject {`(${selectedRowIds.length}) item`}
</Button>
</div>
)}
</RequirePermission>
</div>
{/* Main Card */}
@@ -588,6 +722,10 @@ export function ListDailyChecklistContent() {
}
onPageChange={setPage}
isLoading={isLoadingChecklistList}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
withCheckbox
className={{
containerClassName: cn({
'w-full mb-20':
@@ -666,6 +804,76 @@ export function ListDailyChecklistContent() {
</DialogContent>
</Dialog>
{/* Bulk Approve Modal */}
<Dialog
open={showBulkApproveModal}
onOpenChange={setShowBulkApproveModal}
>
<DialogContent className='sm:max-w-md max-h-[80vh] overflow-y-auto bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Approve Checklist</DialogTitle>
<DialogDescription>
Apakah Anda yakin ingin approve {selectedRowIds.length} checklist
ini?
</DialogDescription>
</DialogHeader>
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
{selectedRowItems.map((item) => (
<div
key={item?.id ?? 0}
className='bg-gray-50 rounded-lg p-4 space-y-2'
>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(item?.date ?? '')}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{item?.kandang?.name ?? '-'}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{item?.category
? (CATEGORY_LABELS[item.category] ?? item?.category)
: item?.category}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Progress:</span>
<span className='font-medium text-gray-900'>
{item?.progress}%
</span>
</div>
</div>
))}
</div>
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowBulkApproveModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmBulkApprove}
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Approve'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Modal */}
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
@@ -735,6 +943,81 @@ export function ListDailyChecklistContent() {
</DialogContent>
</Dialog>
{/* Bulk Reject Modal */}
<Dialog open={showBulkRejectModal} onOpenChange={setShowBulkRejectModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Reject Checklist</DialogTitle>
<DialogDescription>
Berikan alasan reject untuk checklist ini
</DialogDescription>
</DialogHeader>
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
{selectedRowItems.map((item) => (
<div
key={item?.id ?? 0}
className='bg-gray-50 rounded-lg p-4 space-y-2 mb-4'
>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(item?.date ?? '')}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{item?.kandang?.name ?? '-'}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{item?.category
? CATEGORY_LABELS[item.category] || item?.category
: item?.category}
</span>
</div>
</div>
))}
</div>
<div>
<Label htmlFor='reject-reason'>
Alasan Reject <span className='text-red-500'>*</span>
</Label>
<Textarea
id='reject-reason'
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder='Tuliskan alasan reject...'
className='mt-1.5 border-gray-200 min-h-[100px]'
disabled={actionLoading}
/>
</div>
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowBulkRejectModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmBulkReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Reject'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Modal */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
@@ -2,7 +2,14 @@
import { useState, useEffect } from 'react';
import * as React from 'react';
import { ArrowLeft, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import {
ArrowLeft,
CheckCircle,
XCircle,
AlertCircle,
Share2,
} from 'lucide-react';
import * as htmlToImage from 'html-to-image';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Badge } from '@/figma-make/components/base/badge';
@@ -53,6 +60,7 @@ interface ChecklistHeader {
progress_percent: number;
total_phases: number;
total_activities: number;
empty_kandang_end_date?: string | null;
}
interface PhaseGroup {
@@ -106,6 +114,7 @@ const CATEGORY_LABELS: { [key: string]: string } = {
pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close',
empty_kandang: 'Kandang Kosong',
};
const TIME_TYPE_ORDER = ['Umum', 'Pagi', 'Siang', 'Sore', 'Malam'];
@@ -137,6 +146,8 @@ export function DetailDailyChecklistContent() {
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
useEffect(() => {
if (checklistId) {
fetchChecklistDetail();
@@ -169,6 +180,9 @@ export function DetailDailyChecklistContent() {
setDocuments(rawDetailChecklist?.document_urls || []);
const emptyKandangEndDate =
rawDetailChecklist?.empty_kandang?.end_date ?? null;
const checklistData = {
id: rawDetailChecklist?.id,
date: rawDetailChecklist?.date,
@@ -195,6 +209,7 @@ export function DetailDailyChecklistContent() {
progress_percent: 0,
total_phases: 0,
total_activities: 0,
empty_kandang_end_date: emptyKandangEndDate,
});
setLoading(false);
return;
@@ -262,6 +277,7 @@ export function DetailDailyChecklistContent() {
progress_percent: 0,
total_phases: new Set(tasks.map((t) => t.phase_id)).size,
total_activities: tasks.length,
empty_kandang_end_date: emptyKandangEndDate,
});
setLoading(false);
return;
@@ -312,6 +328,7 @@ export function DetailDailyChecklistContent() {
progress_percent: progressPercent,
total_phases: uniquePhases.size,
total_activities: uniqueActivities.size,
empty_kandang_end_date: emptyKandangEndDate,
});
} catch (error) {
console.error('Error fetching checklist detail:', error);
@@ -547,6 +564,103 @@ export function DetailDailyChecklistContent() {
});
};
const isMobileDevice = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
};
const getStatusMessage = () => {
switch (header?.status) {
case 'DRAFT':
return 'Checklist harian perlu disubmit';
case 'SUBMITTED':
return 'Checklist harian menunggu persetujuan';
case 'APPROVED':
return 'Checklist harian telah disetujui';
case 'REJECTED':
return 'Checklist harian telah ditolak';
default:
return '';
}
};
const shareHandler = async () => {
const isMobile = isMobileDevice();
if (isMobile) {
setIsGeneratingImage(true);
}
const baseTitle = `Daily Checklist - ${formatDate(header?.date || '')} - ${header?.kandang_name} - ${header?.category}`;
const statusMsg = getStatusMessage();
const statusInfo = `\nStatus: ${header?.status}${statusMsg ? ` - ${statusMsg}` : ''}`;
const urlMessage = `\n\nView full checklist: ${window.location.href}`;
const fullMessage = baseTitle + statusInfo + urlMessage;
let shareData: ShareData;
if (isMobile) {
const htmlBlob = await htmlToImage.toBlob(document.body, {
backgroundColor: '#ffffff',
});
const imgFile = new File(
[htmlBlob!],
`daily-checklist-${header?.date}-${header?.kandang_name}-${header?.category}.png`,
{
type: 'image/png',
}
);
shareData = {
files: [imgFile],
title: baseTitle,
text: fullMessage,
};
} else {
shareData = {
title: baseTitle,
text: fullMessage,
url: window.location.href,
};
}
setIsGeneratingImage(false);
try {
if (!navigator.canShare(shareData)) {
toast.error(
'Gagal membagikan checklist, coba dengan perangkat yang berbeda'
);
return;
}
await navigator.share(shareData);
toast.success('Checklist berhasil dibagikan');
} catch (error) {
toast.error('Gagal membagikan checklist');
}
};
const shareToWhatsAppHandler = async () => {
const isMobile = isMobileDevice();
setIsGeneratingImage(true);
const statusMsg = getStatusMessage();
const category = header?.category || '';
const message = encodeURIComponent(
`Daily Checklist\n\nTanggal: ${formatDate(header?.date || '')}\nKandang: ${header?.kandang_name}\nKategori: ${CATEGORY_LABELS[category] || category}\nProgress: ${header?.progress_percent}%\nStatus: ${header?.status}${statusMsg ? ` - ${statusMsg}` : ''}\n\nLihat detail lengkap: ${window.location.href}`
);
setIsGeneratingImage(false);
const whatsappUrl = isMobile
? `https://wa.me/?text=${message}`
: `https://web.whatsapp.com/send?text=${message}`;
window.open(whatsappUrl, '_blank');
};
if (loading) {
return (
<div className='min-h-screen'>
@@ -573,8 +687,8 @@ export function DetailDailyChecklistContent() {
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title with Back Button */}
<div className='mb-6 flex items-center gap-4'>
{/* Action Buttons */}
<div className='mb-6 flex items-start sm:items-center justify-between gap-4 flex-wrap'>
<Button
variant='outline'
size='sm'
@@ -584,37 +698,68 @@ export function DetailDailyChecklistContent() {
<ArrowLeft className='w-4 h-4 mr-1' />
Kembali
</Button>
<div className='flex-1'>
<h1 className='text-2xl font-semibold text-gray-900'>
Detail Daily Checklist
</h1>
<p className='text-sm text-gray-600 mt-1'>
Lihat detail checklist harian
</p>
<div className='flex items-center gap-2 flex-wrap'>
{header.status === 'SUBMITTED' && (
<RequirePermission permissions='lti.daily_checklist.create'>
<div className='flex gap-2 flex-wrap'>
<Button
onClick={handleApprove}
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
<CheckCircle className='w-4 h-4 mr-2' />
Approve
</Button>
<Button
onClick={handleReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-2' />
Reject
</Button>
</div>
</RequirePermission>
)}
<Button
variant='outline'
size='sm'
onClick={shareHandler}
disabled={isGeneratingImage}
className='border-gray-200'
>
<Share2 className='w-4 h-4 mr-1' />
{!isGeneratingImage && 'Bagikan'}
{isGeneratingImage && 'Memuat...'}
</Button>
<Button
variant='outline'
size='sm'
onClick={shareToWhatsAppHandler}
disabled={isGeneratingImage}
className='border-gray-200'
>
<Icon icon='mdi:whatsapp' className='w-4 h-4 mr-1' />
{!isGeneratingImage && 'Bagikan via WhatsApp'}
{isGeneratingImage && 'Memuat...'}
</Button>
</div>
{header.status === 'SUBMITTED' && (
<RequirePermission permissions='lti.daily_checklist.create'>
<div className='flex gap-2'>
<Button
onClick={handleApprove}
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
<CheckCircle className='w-4 h-4 mr-2' />
Approve
</Button>
<Button
onClick={handleReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-2' />
Reject
</Button>
</div>
</RequirePermission>
)}
</div>
{/* Page Title */}
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Detail Daily Checklist
</h1>
<p className='text-sm text-gray-600 mt-1'>
Lihat detail checklist harian
</p>
</div>
{/* Header Info Card */}
@@ -639,6 +784,18 @@ export function DetailDailyChecklistContent() {
{CATEGORY_LABELS[header.category] || header.category}
</p>
</div>
{header.category === 'empty_kandang' && (
<div>
<Label className='text-xs text-gray-500'>
Tanggal Selesai Kandang Kosong
</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{header.empty_kandang_end_date
? formatDate(header.empty_kandang_end_date)
: '-'}
</p>
</div>
)}
<div>
<Label className='text-xs text-gray-500'>Status</Label>
<div className='mt-1'>{getStatusBadge(header.status)}</div>
@@ -96,7 +96,10 @@ export function MasterEmployeeContent() {
options: kandangOptions,
loadMore: loadMoreKandang,
isLoadingMore: isLoadingMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
order_by: 'asc',
sort_by: 'name',
});
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
@@ -217,7 +220,9 @@ export function MasterEmployeeContent() {
'Error creating employee:',
createEmployeeResponse.message
);
toast.error('Gagal menambahkan ABK');
toast.error(
'Gagal menambahkan ABK: ' + createEmployeeResponse.message
);
return;
}
@@ -238,7 +243,9 @@ export function MasterEmployeeContent() {
'Error updating employee:',
updateEmployeeResponse.message
);
toast.error('Gagal menambahkan ABK');
toast.error(
'Gagal memperbarui ABK: ' + updateEmployeeResponse.message
);
return;
}
@@ -49,9 +49,8 @@ import { cn } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { UserApi } from '@/services/api/user';
export function MasterKandangContent() {
@@ -108,12 +107,6 @@ export function MasterKandangContent() {
}
);
const {
options: kandangOptions,
isLoadingMore: isLoadingKandangOptionsMore,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name');
const [showModal, setShowModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [kandangToDelete, setKandangToDelete] = useState<number | null>(null);
@@ -375,7 +368,9 @@ export function MasterKandangContent() {
name='search'
placeholder='Cari kandang...'
value={tableFilterState.search}
onChange={(e) => updateFilter('search', e.target.value)}
onChange={(e) =>
updateFilter('search', e.target.value, true)
}
className={{
wrapper: 'w-full sm:w-[280px] border-gray-200',
inputWrapper: 'px-3 py-2 h-fit rounded-md',
@@ -390,7 +385,11 @@ export function MasterKandangContent() {
<Select
value={tableFilterState.location_id}
onValueChange={(value) =>
updateFilter('location_id', value === 'all' ? '' : value)
updateFilter(
'location_id',
value === 'all' ? '' : value,
true
)
}
>
<SelectTrigger className='w-[180px] border-gray-200'>
@@ -0,0 +1,176 @@
'use client';
import { useState } from 'react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { toast } from 'sonner';
import useSWR from 'swr';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { SystemSettingsApi } from '@/services/api/system-settings';
import { SystemSetting } from '@/types/api/system-settings/system-setting';
const ALLOW_NEGATIVE_PAKAN_OVK_KEY = 'allow_negative_pakan_ovk';
function SettingToggle({
setting,
onToggle,
loading,
}: {
setting: SystemSetting;
onToggle: (key: string, currentValue: boolean) => void;
loading: boolean;
}) {
const isEnabled = setting.value === 'true';
return (
<div className='flex items-start justify-between gap-4 py-5'>
<div className='flex-1'>
<p className='text-sm font-medium text-gray-900'>
{setting.key === ALLOW_NEGATIVE_PAKAN_OVK_KEY
? 'Mode Migrasi PAKAN & OVK'
: setting.key}
</p>
{setting.description && (
<p className='text-sm text-gray-500 mt-0.5'>{setting.description}</p>
)}
<span
className={`inline-flex items-center mt-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
isEnabled
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{isEnabled ? 'Aktif' : 'Nonaktif'}
</span>
</div>
<button
type='button'
role='switch'
aria-checked={isEnabled}
disabled={loading}
onClick={() => onToggle(setting.key, isEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-[#0069e0] focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
isEnabled ? 'bg-[#0069e0]' : 'bg-gray-200'
}`}
>
<span
aria-hidden='true'
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
isEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
);
}
export function SystemConfigContent() {
const [toggling, setToggling] = useState<string | null>(null);
const {
data: settingsResponse,
isLoading,
mutate: refreshSettings,
} = useSWR(SystemSettingsApi.basePath, SystemSettingsApi.getAllFetcher, {
keepPreviousData: true,
});
const handleToggle = async (key: string, currentValue: boolean) => {
if (key !== ALLOW_NEGATIVE_PAKAN_OVK_KEY) return;
setToggling(key);
try {
const res = await SystemSettingsApi.setAllowNegativePakanOvk({
value: !currentValue,
});
if (isResponseError(res)) {
toast.error(res.message || 'Gagal mengubah pengaturan');
return;
}
await refreshSettings();
toast.success(
!currentValue
? 'Mode migrasi PAKAN & OVK diaktifkan'
: 'Mode migrasi PAKAN & OVK dinonaktifkan'
);
} catch {
toast.error('Terjadi kesalahan saat mengubah pengaturan');
} finally {
setToggling(null);
}
};
const settings = isResponseSuccess(settingsResponse)
? settingsResponse.data
: [];
if (isLoading && !settingsResponse) {
return (
<div className='min-h-screen'>
<div className='p-6'>
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Konfigurasi Sistem
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data {' '}
<span className='text-[#0069e0]'>Konfigurasi Sistem</span>
</p>
</div>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-12 text-center text-gray-500'>
Memuat data...
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className='min-h-screen'>
<div className='p-6'>
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Konfigurasi Sistem
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data {' '}
<span className='text-[#0069e0]'>Konfigurasi Sistem</span>
</p>
</div>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-0'>
<div className='px-6 py-4 border-b border-gray-200/60'>
<h2 className='text-base font-semibold text-gray-800'>
Pengaturan Global
</h2>
<p className='text-sm text-gray-500 mt-0.5'>
Pengaturan ini berlaku untuk seluruh sistem.
</p>
</div>
<div className='px-6 divide-y divide-gray-200/60'>
{settings.length === 0 ? (
<p className='py-10 text-center text-sm text-gray-500'>
Tidak ada pengaturan tersedia.
</p>
) : (
settings.map((setting) => (
<SettingToggle
key={setting.key}
setting={setting}
onToggle={handleToggle}
loading={toggling === setting.key}
/>
))
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -137,6 +137,8 @@ export function DailyChecklistReportsContent() {
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
area_id: tableFilterState.area_id,
location_id: tableFilterState.location_id,
order_by: 'asc',
sort_by: 'name',
});
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
@@ -159,17 +161,24 @@ export function DailyChecklistReportsContent() {
}
);
const { options: employeeOptions } = useSelect(
EmployeeApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '500',
kandang_id: tableFilterState.kandang_id,
const {
options: employeeOptions,
loadMore: loadMoreEmployee,
isLoadingMore: isLoadingMoreEmployee,
} = useSelect(EmployeeApi.basePath, 'id', 'name', 'search', {
order_by: 'asc',
sort_by: 'name',
kandang_id: tableFilterState.kandang_id,
});
const handleEmployeeScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
if (!isLoadingMoreEmployee) {
loadMoreEmployee();
}
}
);
};
const currentMonthMaxDay = new Date(
Number(tableFilterState.tahun),
@@ -493,7 +502,7 @@ export function DailyChecklistReportsContent() {
>
<SelectValue placeholder='Semua ABK' />
</SelectTrigger>
<SelectContent>
<SelectContent onScroll={handleEmployeeScroll}>
<SelectItem value='ALL'>Semua ABK</SelectItem>
{employeeOptions.map((employee) => (
<SelectItem
@@ -503,6 +512,11 @@ export function DailyChecklistReportsContent() {
{employee.label}
</SelectItem>
))}
{isLoadingMoreEmployee && (
<div className='flex justify-center p-2'>
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
</div>
)}
</SelectContent>
</Select>
</div>
+38
View File
@@ -1,3 +1,4 @@
import axios from 'axios';
import {
BaseApiResponse,
ErrorApiResponse,
@@ -15,3 +16,40 @@ export const isResponseError = <T>(
): res is ErrorApiResponse => {
return res?.status === 'error';
};
export const getErrorMessage = async (
error: unknown,
fallbackMessage: string
) => {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
if (responseData instanceof Blob) {
try {
const parsed = JSON.parse(await responseData.text()) as {
message?: string;
};
return parsed.message || fallbackMessage;
} catch {
return fallbackMessage;
}
}
if (
responseData &&
typeof responseData === 'object' &&
'message' in responseData &&
typeof responseData.message === 'string'
) {
return responseData.message;
}
return error.message || fallbackMessage;
}
if (error instanceof Error) {
return error.message;
}
return fallbackMessage;
};
@@ -9,6 +9,7 @@ import {
DailyChecklist,
DailyChecklistReport,
DetailDailyChecklist,
UpdateDailyChecklistPayload,
} from '@/types/api/daily-checklist/daily-checklist';
import { isResponseError } from '@/lib/api-helper';
import { toast } from 'sonner';
@@ -16,12 +17,39 @@ import { toast } from 'sonner';
export class DailyChecklistApiService extends BaseApiService<
DailyChecklist,
CreateDailyChecklistPayload,
unknown
UpdateDailyChecklistPayload
> {
constructor(basePath: string = '/daily-checklists') {
super(basePath);
}
async update(id: number, payload: UpdateDailyChecklistPayload) {
const isFormData =
typeof FormData !== 'undefined' && payload instanceof FormData;
try {
const updatePath = `${this.basePath}/${id}`;
const headers = isFormData
? { ...(this.header ?? {}) }
: { 'Content-Type': 'application/json', ...(this.header ?? {}) };
const updateRes = await httpClient<BaseApiResponse<DailyChecklist>>(
updatePath,
{
method: 'PUT',
body: payload,
headers,
}
);
return updateRes;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<DailyChecklist>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async getOneDailyChecklist(id: string) {
try {
const getOneDailyChecklistPath = `${this.basePath}/relation/${id}`;
@@ -192,6 +220,29 @@ export class DailyChecklistApiService extends BaseApiService<
}
}
async bulkApprove(ids: string[]) {
try {
const formData = new FormData();
formData.append('ids', ids.join(','));
formData.append('status', 'APPROVED');
formData.append('reject_reason', '');
const approvePath = `${this.basePath}/bulk-update`;
const approveRes = await httpClient<BaseApiResponse>(approvePath, {
method: 'PATCH',
body: formData,
});
return approveRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async reject(id: string, rejectReason: string) {
try {
const formData = new FormData();
@@ -215,6 +266,29 @@ export class DailyChecklistApiService extends BaseApiService<
}
}
async bulkReject(ids: string[], rejectReason: string) {
try {
const formData = new FormData();
formData.append('ids', ids.join(','));
formData.append('status', 'REJECTED');
formData.append('reject_reason', rejectReason);
const rejectPath = `${this.basePath}/bulk-update`;
const rejectRes = await httpClient<BaseApiResponse>(rejectPath, {
method: 'PATCH',
body: formData,
});
return rejectRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse>(error)) {
return error.response?.data;
}
return undefined;
}
}
async uploadImage(
id: number,
status: string,
+134
View File
@@ -2,12 +2,14 @@ import axios from 'axios';
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse, GroupedApprovals } from '@/types/api/api-general';
import {
BulkApproveExpensePayload,
CreateExpensePayload,
CreateExpenseRealizationPayload,
Expense,
UpdateExpensePayload,
} from '@/types/api/expense';
import { httpClient } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
export class ExpenseApiService extends BaseApiService<
Expense,
@@ -330,6 +332,65 @@ export class ExpenseApiService extends BaseApiService<
}
}
async bulkApproveToStatus(
payload: BulkApproveExpensePayload
): Promise<BaseApiResponse<Expense | Expense[]> | undefined> {
try {
return await httpClient<BaseApiResponse<Expense | Expense[]>>(
`${this.basePath}/approvals/bulk`,
{
method: 'POST',
body: payload,
}
);
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<Expense | Expense[]>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async bulkApprovals(
ids: number[],
status: BulkApproveExpensePayload['status'] | 'SELESAI',
date?: string,
notes?: string
): Promise<BaseApiResponse<Expense | Expense[]> | undefined> {
if (status === 'SELESAI') {
const responses = await Promise.all(ids.map((id) => this.complete(id)));
const failedResponse = responses.find(
(response) => response?.status !== 'success'
);
if (failedResponse) {
return failedResponse;
}
const completedExpenses = responses.flatMap((response) =>
response?.status === 'success' ? [response.data] : []
);
return {
code: 200,
status: 'success',
message:
completedExpenses.length === 1
? 'Submit expense approval successfully'
: 'Submit expense approvals successfully',
data: completedExpenses,
};
}
return this.bulkApproveToStatus({
approvable_ids: ids,
status,
date: date || undefined,
notes: notes || undefined,
});
}
async rejectHeadArea(
id: number,
notes?: string
@@ -511,6 +572,25 @@ export class ExpenseApiService extends BaseApiService<
}
}
async setExpensePaidOff(id: number) {
try {
const res = await httpClient<BaseApiResponse<Expense>>(
`${this.basePath}/${id}/pay`,
{
method: 'PATCH',
}
);
return res;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<Expense>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async deleteExpenseRequestDocument(
expenseId: number,
documentId: number
@@ -646,6 +726,60 @@ export class ExpenseApiService extends BaseApiService<
return formData;
};
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('type', 'all');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `BOP-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async exportInputProgressToExcel(startDate: string, endDate: string) {
const params = new URLSearchParams();
params.set('export', 'excel');
params.set('type', 'progress');
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `input-progres-BOP-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
}
export const ExpenseApi = new ExpenseApiService('/expenses');
+41 -1
View File
@@ -13,7 +13,9 @@ import {
CreateInventoryAdjustmentPayload,
InventoryAdjustment,
} from '@/types/api/inventory/adjustment';
import { InventoryProduct } from '@/types/api/inventory/product';
import { InventoryProduct, StockLog } from '@/types/api/inventory/product';
import { httpClient } from '../http/client';
import { formatDate } from '@/lib/helper';
export const ProductWarehouseApi = new BaseApiService<
ProductWarehouse,
@@ -65,3 +67,41 @@ export const InventoryProductApi = new BaseApiService<
unknown,
unknown
>('/inventory/product-stocks');
export class StockLogService extends BaseApiService<
StockLog,
unknown,
unknown
> {
constructor(basePath: string = '/inventory/stock-logs') {
super(basePath);
}
async exportToExcel(warehouseName: string, initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `informasi-stok-produk-${warehouseName.toLowerCase().replaceAll(' ', '-')}-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
}
export const StockLogApi = new StockLogService('/inventory/stock-logs');
+87 -53
View File
@@ -1,17 +1,17 @@
import { isResponseError } from '@/lib/api-helper';
import { BaseApiService } from '@/services/api/base';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import axios from 'axios';
import {
BulkApproveMarketingPayload,
Marketing,
CreateSalesOrderPayload,
UpdateSalesOrderPayload,
CreateDeliveryOrderPayload,
UpdateDeliveryOrderPayload,
} from '@/types/api/marketing/marketing';
import toast from 'react-hot-toast';
import * as XLSX from 'xlsx';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { formatDate } from '@/lib/helper';
/**
* 💡 Helper untuk membuat respons dummy
@@ -73,6 +73,26 @@ export class SalesOrderService extends BaseApiService<
}
}
async bulkApproveToStatus(
payload: BulkApproveMarketingPayload
): Promise<BaseApiResponse<Marketing | Marketing[]> | undefined> {
try {
return await httpClient<BaseApiResponse<Marketing | Marketing[]>>(
'/marketing/approvals/bulk',
{
method: 'POST',
body: payload,
}
);
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<Marketing | Marketing[]>>(error)) {
return error.response?.data;
}
return undefined;
}
}
/**
* Delivery
*/
@@ -104,67 +124,81 @@ class MarketingExportService extends BaseApiService<
super(basePath);
}
/**
* Export to Excel
*/
async bulkApprovals(
ids: number[],
status: 'SALES_ORDER' | 'DELIVERY_ORDER',
date: string, // YYYY-MM-DD
notes: string
): Promise<BaseApiResponse<Marketing[] | Marketing> | undefined> {
try {
const path = `${this.basePath}/approvals/bulk`;
return await httpClient<BaseApiResponse<Marketing[] | Marketing>>(path, {
method: 'POST',
body: {
approvable_ids: ids,
status: status,
date: date,
notes: notes,
},
});
} catch (error) {
throw error;
}
}
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('type', 'all');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
try {
const marketingData = await httpClientFetcher<
BaseApiResponse<Marketing[]>
>(`${this.basePath}${queryString}`);
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
if (isResponseError(marketingData)) {
toast.error('Gagal melakukan export marketing! Coba lagi.');
return;
}
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const rows = marketingData.data;
const fileName = `penjualan-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
const formattedRows = [];
document.body.appendChild(link);
link.click();
link.remove();
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const approval = row.latest_approval;
const isRejected = approval?.action === 'REJECTED';
async exportInputProgressToExcel(startDate: string, endDate: string) {
const params = new URLSearchParams();
// Calculate grand total from sales_order products
const grandTotal =
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0;
params.set('export', 'excel');
params.set('type', 'progress');
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
// Get product names
const products =
row.sales_order
?.map((product) => product.product_warehouse?.product?.name)
.filter(Boolean)
.join(', ') ?? '';
const queryString = `?${params.toString()}`;
formattedRows.push({
'No. Order': row.so_number,
Tanggal: formatDate(row.so_date, 'DD-MM-YYYY'),
Status: isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || ''),
Customer: row.customer?.name || '',
'Grand Total': formatCurrency(grandTotal),
Products: products,
Notes: row.notes || '',
});
}
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
const ws = XLSX.utils.json_to_sheet(formattedRows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'marketing');
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
// triggers download in browser
XLSX.writeFile(wb, 'marketing.xlsx');
} catch {
toast.error('Gagal melakukan export marketing! Coba lagi.');
}
const fileName = `input-progres-penjualan-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
}
+29
View File
@@ -95,6 +95,8 @@ export class RecordingService extends BaseApiService<
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
@@ -114,6 +116,33 @@ export class RecordingService extends BaseApiService<
link.click();
link.remove();
}
async exportInputProgressToExcel(startDate: string, endDate: string) {
const params = new URLSearchParams();
params.set('export', 'excel');
params.set('type', 'progress');
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `input-progres-recording-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
}
export const RecordingApi = new RecordingService('/production/recordings');
+72
View File
@@ -10,6 +10,8 @@ import {
} from '@/types/api/purchase/purchase';
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { formatDate } from '@/lib/helper';
import { httpClient } from '../http/client';
const basePurchaseApi = new BaseApiService<
Purchase,
@@ -112,4 +114,74 @@ export const PurchaseApi = {
});
},
},
updatePoDate: async (
purchaseRequestId: number,
payload: { po_date: string }
): Promise<BaseApiResponse<Purchase> | undefined> => {
return await basePurchaseApi.customRequest<BaseApiResponse<Purchase>>(
`${purchaseRequestId}/po-date`,
{
method: 'PATCH',
payload,
}
);
},
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('type', 'all');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `pembelian-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
},
async exportInputProgressToExcel(startDate: string, endDate: string) {
const params = new URLSearchParams();
params.set('export', 'excel');
params.set('type', 'progress');
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(
`${basePurchaseApi.basePath}${queryString}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `input-progres-pembelian-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
},
};
-22
View File
@@ -1,22 +0,0 @@
import { BaseApiService } from '@/services/api/base';
import { httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { ReportExpense } from '@/types/api/report/report-expense';
export class ReportExpenseApiService extends BaseApiService<
ReportExpense,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
async getAllFetcher(
endpoint: string
): Promise<BaseApiResponse<ReportExpense[]>> {
return await httpClientFetcher<BaseApiResponse<ReportExpense[]>>(endpoint);
}
}
export const ReportExpenseApi = new ReportExpenseApiService('/reports/expense');

Some files were not shown because too many files have changed in this diff Show More