Compare commits

..

514 Commits

Author SHA1 Message Date
Rivaldi A N S 55424b272f Merge branch 'fix/depreciation-report' into 'development'
[FIX/FE] Depreciation Report

See merge request mbugroup/lti-web-client!491
2026-05-22 04:17:04 +00:00
ValdiANS 05138dbb6f feat: implement table filter state persist 2026-05-22 11:14:16 +07:00
ValdiANS b5a0614218 feat: implement url query param tab navigation 2026-05-22 11:13:52 +07:00
Rivaldi A N S 7ec46ffa8c Merge branch 'fix/transfer-stock' into 'development'
[FIX/FE] Transfer Stock

See merge request mbugroup/lti-web-client!490
2026-05-22 03:19:56 +00:00
ValdiANS 07dd2d26be fix: cache product stock 2026-05-22 10:17:04 +07:00
Rivaldi A N S 9a56bf732a Merge branch 'fix/purchase-filter' into 'development'
[FIX/FE] Purchase Filter

See merge request mbugroup/lti-web-client!489
2026-05-21 07:46:44 +00:00
ValdiANS 585918cc28 fix: update purchase type 2026-05-21 14:44:28 +07:00
ValdiANS 80e0bd5a8e fix: update table columns 2026-05-21 14:44:20 +07:00
ValdiANS a4e5116bef feat: add start_date, end_date, and filter_by input 2026-05-21 14:44:07 +07:00
ValdiANS 027668a1bf feat: add export to excel feature 2026-05-21 14:43:08 +07:00
Rivaldi A N S 7e1fab9a69 Merge branch 'feat/balance-monitoring-report' into 'development'
[FEAT/FE] Balance Monitoring Report

See merge request mbugroup/lti-web-client!488
2026-05-20 09:41:51 +00:00
ValdiANS ef56f87e45 feat: create report finance layout file 2026-05-20 16:35:43 +07:00
ValdiANS c4827bb810 feat: implement Query Param Tab Navigation 2026-05-20 16:35:26 +07:00
ValdiANS 9abb8b0b58 feat: add hide field in TabItem type 2026-05-20 16:34:53 +07:00
ValdiANS 8d014a8fea fix: adjust BalanceMonitoringRow type 2026-05-20 16:14:37 +07:00
ValdiANS 3d37fb2ecb fix: remove dummy data 2026-05-20 16:10:36 +07:00
ValdiANS d60877d391 refactor: optimize DebtSupplierTab with useTableFilter persistence pattern
Replace filterParams/currentPage/pageSize state with useTableFilter (persist:true),
switch SWR to httpClientFetcher with explicit type, store OptionType[] directly for
suppliers/filterBy, add formikResetHandler using resetFilter(), remove TabActions
component anti-pattern and handleFilterModalOpenRef, pass filterModal.openModal directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:10:19 +07:00
ValdiANS b3b60018bb refactor: optimize CustomerPaymentTab with useTableFilter persistence pattern
Replace filterParams/currentPage/pageSize state with useTableFilter (persist:true),
switch SWR to httpClientFetcher with explicit type, store OptionType[] directly for
customers/filterBy, add formikResetHandler using resetFilter(), remove enableReinitialize
and handleFilterModalOpenRef, pass filterModal.openModal directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:10:10 +07:00
ValdiANS c98a51326f refactor: optimize BalanceMonitoringTab with useTableFilter persistence pattern
Replace single-select customerFilter/salesFilter with OptionType[] multi-select
(customers, salesPersons, filterBy), switch SWR to httpClientFetcher with explicit
type, remove PDF export, enableReinitialize, useRef modal hack, useMemo on data/meta,
and useCallback on trivial handlers. Add formikResetHandler using resetFilter().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:10:01 +07:00
ValdiANS 7437e2e584 fix: update pattern context 2026-05-20 16:08:19 +07:00
ValdiANS ac6f6ecf78 Merge branch 'development' into feat/balance-monitoring-report 2026-05-20 11:21:39 +07:00
Rivaldi A N S 7f961b2f8b Merge branch 'feat/debt-supplier-general-export' into 'development'
[FEAT/FE] Debt Supplier General Export

See merge request mbugroup/lti-web-client!487
2026-05-20 04:14:59 +00:00
ValdiANS a8c02243a4 feat: implement export general and server-side export 2026-05-20 11:13:50 +07:00
Rivaldi A N S 82dca3b57e Merge branch 'feat/debt-supplier-general-export' into 'development'
[FEAT/FE] Debt Supplier General Export

See merge request mbugroup/lti-web-client!486
2026-05-19 10:33:48 +00:00
ValdiANS 94d623d793 feat: update gitignore 2026-05-19 17:31:18 +07:00
ValdiANS f76b5b981c feat: create balance-monitoring type 2026-05-19 17:31:00 +07:00
ValdiANS 8df5af0124 feat: create getBalanceMonitoringReport method 2026-05-19 17:30:38 +07:00
ValdiANS 3c175d4586 feat: create BalanceMonitoringTab component 2026-05-19 17:30:28 +07:00
ValdiANS 9350a6bd3e fix: add Monitoring Saldo tab 2026-05-19 17:30:13 +07:00
ValdiANS 6668c7b1f9 feat: update .gitignore 2026-05-19 16:07:08 +07:00
ValdiANS ce4f50c92a feat: create Export to Excel - General button 2026-05-19 16:06:54 +07:00
ValdiANS 146192a5b3 feat: create exportToExcelGeneral method 2026-05-19 16:06:41 +07:00
Rivaldi A N S 27c24e7c82 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!485
2026-05-19 07:47:51 +00:00
ValdiANS a99a399f09 fix: show kandang label even if its not loaded yet in kandang options 2026-05-19 14:44:12 +07:00
Rivaldi A N S 37d0041a4f Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!483
2026-05-19 05:12:37 +00:00
ValdiANS 3647f1a1ea fix: make empty kandang end date required 2026-05-19 12:11:24 +07:00
ValdiANS 7b5049165a fix: add hasError props 2026-05-19 12:11:07 +07:00
Rivaldi A N S 3839b46edc Merge branch 'feat/server-side-sorting' into 'development'
[FEAT/FE] Server Side Sorting

See merge request mbugroup/lti-web-client!482
2026-05-19 04:54:47 +00:00
ValdiANS b7f2bca931 feat: add rtk filters.toml 2026-05-19 11:51:59 +07:00
ValdiANS 802bf77bc5 feat: add rtk instructions 2026-05-19 11:51:27 +07:00
ValdiANS fd7b49ab93 feat: implement server-side sorting in report expense 2026-05-19 11:51:17 +07:00
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
Rivaldi A N S 11353809f0 Merge branch 'feat/expense-enhancement' into 'development'
[FEAT/FE] Expense Enhancement

See merge request mbugroup/lti-web-client!404
2026-04-15 09:43:06 +00:00
ValdiANS 6463b7a572 fix: set resetPage to false as default value in updateFilter function 2026-04-15 16:39:22 +07:00
ValdiANS 7a5ee2aca1 feat: implement return to url query param 2026-04-15 16:38:56 +07:00
ValdiANS 5e907d7e53 feat: create expense navigation helper function 2026-04-15 16:35:35 +07:00
Adnan Zahir 71edc9c68a Merge branch 'fix/recording' into 'development'
[FIX][FE]: adjust value query param get product warehouses

See merge request mbugroup/lti-web-client!403
2026-04-14 15:15:35 +07:00
MacBook Air M1 2a33fdbbbe adjust value query param get product warehouses 2026-04-14 15:05:08 +07:00
Adnan Zahir 178c659b58 Merge branch 'codex/uniformity-week-calculation' into 'development'
codex/fix: uniformity week calculation

See merge request mbugroup/lti-web-client!402
2026-04-14 14:33:04 +07:00
Rivaldi A N S c1d6436583 Merge branch 'fix/adjustment-issue-14-apr-26' into 'development'
[FIX/FE] Fix Unnecessary Error Label (OptionType) on Purchase Approval Form (Purchase)

See merge request mbugroup/lti-web-client!401
2026-04-14 06:59:11 +00:00
rstubryan 8dc62453bd fix(FE-form-object-missmatch): Refactor purchase item handling in
approval forms and schemas
2026-04-14 13:31:40 +07:00
Adnan Zahir 244d800874 codex/fix: uniformity week calculation 2026-04-14 13:10:53 +07:00
Rivaldi A N S 52dd1613bb Merge branch 'fix/expense-report-filter' into 'development'
[FIX/FE] Expense Report Filter

See merge request mbugroup/lti-web-client!399
2026-04-13 09:32:29 +00:00
ValdiANS 57ea81fdf2 fix: change kandang_id to project_flock_kandang_id in report expense params 2026-04-13 16:30:57 +07:00
Rivaldi A N S 90742604cb Merge branch 'fix/expense-realization-detail' into 'development'
[FIX/FE] Expense Realization Detail

See merge request mbugroup/lti-web-client!398
2026-04-13 08:41:42 +00:00
ValdiANS 4b8853b766 fix: implement lazy loading in nontstock select input 2026-04-13 15:31:04 +07:00
ValdiANS 7168270527 fix: use isNaN to check valid kandang ID 2026-04-13 15:28:25 +07:00
Rivaldi A N S 47b186e195 Merge branch 'fix/adjustment-issue-13-apr-26' into 'development'
[FIX/FE] Adjustment Endpoint Kandang on Dashboard Filter (Project Flock Kandang) and Add Param has_marketing on Marketing Filter

See merge request mbugroup/lti-web-client!397
2026-04-13 07:58:19 +00:00
rstubryan ff39514b78 refactor(FE-endpoint-path): Fix customer API integration in
MarketingFilter
2026-04-13 14:55:00 +07:00
rstubryan f97b6fc218 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/adjustment-issue-13-apr-26 2026-04-13 14:38:54 +07:00
rstubryan 62dc8235d4 refactor(FE-change-api): Refactor Kandang input handling in
DashboardProduction
2026-04-13 14:37:23 +07:00
rstubryan 73d05d6b4b refactor(FE-add-param): Update MarketingFilter to refine API calls and
options handling
2026-04-13 14:23:21 +07:00
Rivaldi A N S 3c44906a20 Merge branch 'fix/production-result-report-filter' into 'development'
[FIX/FE] Production Result Report

See merge request mbugroup/lti-web-client!395
2026-04-13 05:24:07 +00:00
ValdiANS e4b1deecdc fix: render one project flock kandang if kandang is selected 2026-04-13 12:19:38 +07:00
ValdiANS 12afa88f2c fix: make kandang_id optional 2026-04-13 12:19:08 +07:00
Adnan Zahir 0527155bb9 Merge branch 'fix/adjustment-issue-13-apr-26' into 'development'
[FIX/FE] Adjustment Load More Data (useSelect Biaya), Marketing Customer Issue and Remove Fetch Kandang at Dashboard

See merge request mbugroup/lti-web-client!394
2026-04-13 12:13:11 +07:00
rstubryan 34a45d084b refactor(FE-modal-close-when-reset): Close filter modal when resetting
dashboard filters
2026-04-13 11:57:31 +07:00
rstubryan 9de897dfbd Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-04-13 11:51:01 +07:00
rstubryan 907f6664e1 refactor(FE-remove-kandang-fetch): Refactor Kandang select logic to use
derived options
2026-04-13 11:48:32 +07:00
rstubryan 3ad04e5bac refactor(FE-remove-filter): Make select inputs clearable in
DashboardProduction and remove filter from kandang
2026-04-13 11:33:40 +07:00
ValdiANS 4649dfde89 Merge branch 'development' into fix/expense-report-filter 2026-04-13 11:21:03 +07:00
rstubryan b580a01bdc refactor(FE-formik-usage): Refactor MarketingFilter form values and
handlers
2026-04-13 11:13:39 +07:00
Rivaldi A N S 15ec6c3b9c Merge branch 'fix/report-default-filter-value' into 'development'
[FIX/FE] Report Default Filter Value

See merge request mbugroup/lti-web-client!392
2026-04-13 04:02:06 +00:00
ValdiANS 8b970aeb64 fix: open filter modal when component is mounted 2026-04-13 11:01:00 +07:00
ValdiANS de6fd2367e fix: set filter default value 2026-04-13 11:00:02 +07:00
ValdiANS 3fedbc7ffb fix: adjust showEditButton condition 2026-04-13 10:58:33 +07:00
ValdiANS 9e297cc0a4 chore: remove unnecessary code 2026-04-13 10:58:23 +07:00
rstubryan 6ff3a715e0 refactor(FE-load-more-option): Add infinite scroll to location and
supplier dropdowns
2026-04-13 10:46:51 +07:00
ValdiANS cd4cef883e fix: change kandang filter to project flock kandang with period 2026-04-13 10:08:09 +07:00
Rivaldi A N S d853781c17 Merge branch 'fix/laporan-rekapitulasi-pembelian-per-supplier' into 'development'
[FIX/FE] Laporan Rekapitulasi Pembelian Per Supplier

See merge request mbugroup/lti-web-client!391
2026-04-13 02:48:37 +00:00
ValdiANS 8faed2e561 fix: implement lazy loading in select input in filter modal 2026-04-13 09:44:38 +07:00
Rivaldi A N S 8aeef46ee3 Merge branch 'fix/closing-kandang-button' into 'development'
[FIX/FE] Closing Kandang Button

See merge request mbugroup/lti-web-client!390
2026-04-13 02:36:14 +00:00
ValdiANS c9000c1e2c fix: disable button if kandang is selected 2026-04-13 09:33:41 +07:00
ValdiANS bb3541090a fix: pass kandang ID ClosingKandangList component 2026-04-13 09:33:23 +07:00
ValdiANS ad0e617ed0 fix: render button if href is provided and is disabled 2026-04-13 09:33:01 +07:00
Rivaldi A N S 73ef1c2ece Merge branch 'fix/daily-sales-report' into 'development'
[FIX/FE] Daily Sales Report

See merge request mbugroup/lti-web-client!389
2026-04-12 14:37:55 +00:00
ValdiANS 9cf0d15c33 fix: implement lazy loading for select input 2026-04-12 21:36:40 +07:00
Rivaldi A N S c66f7b1cbf Merge branch 'fix/hpp-report' into 'development'
[FIX/FE] HPP Report

See merge request mbugroup/lti-web-client!387
2026-04-10 10:08:27 +00:00
ValdiANS 17ebc31f00 fix: implement infinite scroll in select input 2026-04-10 17:07:07 +07:00
Rivaldi A N S ce0b4d744c Merge branch 'fix/marketing-delivery-order' into 'development'
[FIX/FE] Marketing Delivery Order

See merge request mbugroup/lti-web-client!386
2026-04-10 09:15:15 +00:00
ValdiANS f6f3290743 fix: add weight_per_convertion to BaseDelivery 2026-04-10 16:13:02 +07:00
ValdiANS 31a4dec8a3 fix: get weight_per_convertion from delivery order first 2026-04-10 16:12:47 +07:00
ValdiANS fbb6f87368 fix: add weight_per_convertion to payload when creating/updating delivery order 2026-04-10 16:12:35 +07:00
Rivaldi A N S d88d71fb16 Merge branch 'fix/marketing-delivery-order' into 'development'
[FIX/FE] Marketing Delivery Order

See merge request mbugroup/lti-web-client!385
2026-04-10 08:13:27 +00:00
ValdiANS 570024c2e6 chore: prettier format 2026-04-10 15:08:13 +07:00
ValdiANS 70556d04ba fix: calculate unit price by weight 2026-04-10 15:07:39 +07:00
ValdiANS 00434002a7 fix: add harga satuan per peti 2026-04-10 15:06:57 +07:00
ValdiANS bcb1e0b5b6 fix: add (Kg) label to Total Harga Satuan 2026-04-10 15:06:38 +07:00
ValdiANS 03a6aabf1f fix: adjust initialPricePerConvertion value 2026-04-10 15:06:06 +07:00
ValdiANS 47adaa4f92 fix: return total_peti, weight_per_convertion, and price_per_convertion in DeliveryProductToFieldValues function 2026-04-10 15:02:48 +07:00
Rivaldi A N S f2cdbd497a Merge branch 'fix/marketing-delivery-order' into 'development'
[FIX/FE] Marketing Delivery Order

See merge request mbugroup/lti-web-client!384
2026-04-09 09:38:58 +00:00
ValdiANS 4ffea739a9 fix: comment edit button in renderSalesOrderContent 2026-04-09 16:37:35 +07:00
ValdiANS bf5591d61d fix: prioritize DO data if delivery orders exist 2026-04-09 16:36:59 +07:00
Rivaldi A N S a725ae4891 Merge branch 'fix/uniformity-form' into 'development'
[FIX/FE] Uniformity Form

See merge request mbugroup/lti-web-client!383
2026-04-09 08:45:55 +00:00
ValdiANS f5d3fb3b9d uncomment pre-commit 2026-04-09 15:14:07 +07:00
ValdiANS 4a6c443003 fix: disable uniformity form uploading uniformity file 2026-04-09 15:13:16 +07:00
Rivaldi A N S 09f4af3ece Merge branch 'feat/recording-export' into 'development'
[FEAT/FE] Recording

See merge request mbugroup/lti-web-client!381
2026-04-09 07:24:57 +00:00
ValdiANS 62d250109b feat: add responseType to axios config 2026-04-09 14:15:29 +07:00
ValdiANS e50f4dbddb feat: add exportToExcel method to RecordingService 2026-04-09 14:15:12 +07:00
ValdiANS c898154b48 feat: add export button 2026-04-09 14:14:50 +07:00
Rivaldi A N S acb02c9bdc Merge branch 'fix/marketing-delivery-order' into 'development'
[FIX/FE] Marketing Delivery Order

See merge request mbugroup/lti-web-client!380
2026-04-09 04:26:47 +00:00
ValdiANS 986f429ea9 fix: add edit button to delivery item 2026-04-09 11:21:20 +07:00
ValdiANS 1dafb0d365 fix: adjust DeliveryProductToFieldValues and mergeSOwithDO return values 2026-04-09 11:09:04 +07:00
ValdiANS 095b1c5850 fix: adjust selected delivery product priority order 2026-04-09 11:04:57 +07:00
Rivaldi A N S 4297502c55 Merge branch 'fix/purchase-product-receive-confirmation' into 'development'
[FIX/FE] Purchase Product Receive Confirmation

See merge request mbugroup/lti-web-client!379
2026-04-08 08:37:44 +00:00
ValdiANS 726065da51 fix: make vehicle_number and transport_per_item required if expedition_vendor exist 2026-04-08 15:32:01 +07:00
ValdiANS 6c03e42006 fix: remove transport_per_item and vehicle_number value and disable them if expedition vendor is empty 2026-04-08 15:31:31 +07:00
ValdiANS 5c39e900f3 chore: remove unnecessary code 2026-04-08 15:30:37 +07:00
ValdiANS 68c1655824 fix: adjust unit_price and price_per_qty value to match the SalesOrderProductForm 2026-04-08 13:57:09 +07:00
Rivaldi A N S 0ef8c06e41 Merge branch 'fix/adjustment-issue-8-apr-26' into 'development'
[FIX/FE] Adjustment Filter Default Value Set Range to This Month

See merge request mbugroup/lti-web-client!377
2026-04-08 04:32:46 +00:00
rstubryan 68b25332b1 refactor(FE-set-to-end): Fix date range to include the end of the month 2026-04-08 11:29:51 +07:00
rstubryan b402a06706 feat(FE-moment-default-range): Set default date range to current month
in Dashboard component
2026-04-08 10:15:33 +07:00
Rivaldi A N S 7df2fad959 Merge branch 'fix/adjustment-dashboard-modal' into 'development'
[FIX/FE] Adjustment Dashboard Modal (Open Filter Issue)

See merge request mbugroup/lti-web-client!376
2026-04-08 02:59:37 +00:00
rstubryan 0b52fff5f5 refactor(FE-dashboard-modal): Refactor dashboard production data
fetching logic
2026-04-08 09:54:40 +07:00
Rivaldi A N S e251ab9eb4 Merge branch 'fix/marketing' into 'development'
[FIX/FE] Marketing - Sales Order Form

See merge request mbugroup/lti-web-client!374
2026-04-07 10:44:49 +00:00
ValdiANS 9f0fbcf041 fix: make price_per_qty calculation to price per kg and make unit_price calculation to price per egg (if category is TELUR and convertion unit QTY) 2026-04-07 17:24:19 +07:00
ValdiANS 05fbae680f fix: reorder input for price_per_qty and unit_price 2026-04-07 17:23:23 +07:00
ValdiANS 444c475cb4 fix: remove formattedUnitPrice 2026-04-07 17:22:46 +07:00
ValdiANS ef1ce2c78c fix: remove roundPrice, update unit price calculation in calculateTelurPeti 2026-04-07 16:59:35 +07:00
ValdiANS 429ff58bfd chore: remove unnecessary code 2026-04-07 16:57:01 +07:00
ValdiANS 8961004000 fix: set initialPricePerConvertion to unit_price 2026-04-07 16:55:39 +07:00
ValdiANS 2dc3bcf9f0 fix: make convertion unit support QTY when hitting update/create API 2026-04-07 16:55:23 +07:00
Rivaldi A N S 9c31705865 Merge branch 'fix/adjustment-recording-form' into 'development'
[FIX/FE] Adjustment Zero Restriction on Jumlah Pakai (>0. value) and Add Refetch (Invalidate) at Recording Detail Page

See merge request mbugroup/lti-web-client!372
2026-04-07 09:13:40 +00:00
rstubryan b89730ab68 feat(FE-invalidate-mutation): Refactor SWR keys for recording detail
pages and add cache invalidation
2026-04-07 16:06:46 +07:00
rstubryan 6e34eede4b refactor(FE-jumlah-pakai-zero-restriction): Update validation message
for qty in StockObjectSchema
2026-04-07 16:01:59 +07:00
Rivaldi A N S ffd5e70947 Merge branch 'fix/adjustment-recording-form' into 'development'
[FIX/FE] Adjustment Comma's value for Jumlah Pakai Field and Commented Out Param at (location_id) Egg Product (Product Warehouse) Endpoint

See merge request mbugroup/lti-web-client!371
2026-04-07 08:32:54 +00:00
rstubryan 2f89c6f216 refactor(FE-unused-param): Comment out unused eggProductsLocationId
state and references
2026-04-07 15:26:25 +07:00
rstubryan ebf966228b refactor(FE-decimal-jumlah-pakai): Increase decimal scale for stock
quantity input to 3
2026-04-07 15:23:31 +07:00
Rivaldi A N S ebe1d77c72 Merge branch 'fix/marketing' into 'development'
[FIX/FE] Marketing

See merge request mbugroup/lti-web-client!370
2026-04-07 06:12:03 +00:00
ValdiANS 922a93414f fix: adjust unit price placeholder 2026-04-07 13:11:14 +07:00
Rivaldi A N S 854a1e7c4c Merge branch 'fix/recording' into 'development'
[FIX/FE] Recording

See merge request mbugroup/lti-web-client!369
2026-04-07 04:55:57 +00:00
ValdiANS 129a3fda44 fix: memoized formattedSuccessRawData and formattedErrorRawData 2026-04-07 11:54:00 +07:00
ValdiANS 981b48acc0 fix: check deep equality 2026-04-07 11:53:34 +07:00
Adnan Zahir dd5bbf0ac6 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: invisible depletion and egg <= 0

See merge request mbugroup/lti-web-client!368
2026-04-06 22:32:46 +07:00
Adnan Zahir 8872b283ac codex/fix: invisible depletion and egg <= 0 2026-04-06 22:28:39 +07:00
Adnan Zahir 860c9dec22 Merge branch 'codex/sales-at-farm-level' into 'development'
Codex/sales at farm level

See merge request mbugroup/lti-web-client!367
2026-04-04 10:08:36 +07:00
Adnan Zahir 0cd6c9bd2f Merge branch 'production' into development 2026-04-04 09:59:36 +07:00
Adnan Zahir 107d412c10 formatting 2026-04-04 09:57:01 +07:00
Adnan Zahir c6d8533190 codex/fix: inconsistent stock options and availability 2026-04-04 09:53:10 +07:00
Adnan Zahir ae90d55f81 Merge branch 'hotfix/bug-reported-by-user' into 'production'
[HOTFIX/FE] Adjustment

See merge request mbugroup/lti-web-client!365
2026-04-02 14:37:14 +07:00
ValdiANS c155717459 fix: use correct address and logo 2026-04-02 11:44:08 +07:00
ValdiANS d74de4b2d9 fix: use correct address 2026-04-02 11:44:00 +07:00
ValdiANS 529ba21f47 feat: create PurchaseFilter type 2026-04-02 11:41:02 +07:00
ValdiANS 449c2030fe feat: add filter modal 2026-04-02 11:40:53 +07:00
ValdiANS 679740972f feat: create PurchaseFilterModal component 2026-04-02 11:40:43 +07:00
ValdiANS 57b8326fdf fix: change from parseInt to parseFloat 2026-04-02 11:32:09 +07:00
ValdiANS e26b5127c5 fix: parse to float numberFormatValues.value 2026-04-02 11:28:10 +07:00
Rivaldi A N S 1a4a1e8e56 Merge branch 'fix/purchase-pdf-logo' into 'development'
[FIX/FE] Purchase PDF Logo

See merge request mbugroup/lti-web-client!364
2026-04-02 04:01:59 +00:00
ValdiANS 10d1f05aa5 fix: use correct address and logo 2026-04-02 11:00:17 +07:00
ValdiANS e4b6238771 fix: use correct address 2026-04-02 11:00:08 +07:00
Rivaldi A N S fc89922ed1 Merge branch 'fix/purchasing-filter' into 'development'
[FIX/FE] Purchase Filter

See merge request mbugroup/lti-web-client!363
2026-04-02 03:00:15 +00:00
ValdiANS 25b5165249 feat: create PurchaseFilter type 2026-04-02 09:57:48 +07:00
ValdiANS cae19d905b feat: add filter modal 2026-04-02 09:57:40 +07:00
ValdiANS c040c0e9bb feat: create PurchaseFilterModal component 2026-04-02 09:51:22 +07:00
Rivaldi A N S b3bd7563fa Merge branch 'fix/master-data-product' into 'development'
[FIX/FE] Master Data Product

See merge request mbugroup/lti-web-client!362
2026-04-01 09:13:34 +00:00
ValdiANS edf21fbfc4 fix: change from parseInt to parseFloat 2026-04-01 16:13:13 +07:00
ValdiANS 65f31f8340 fix: parse to float numberFormatValues.value 2026-04-01 16:13:01 +07:00
Adnan Zahir 772087bacd Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: show farm stock usage on closing page

See merge request mbugroup/lti-web-client!361
2026-04-01 12:33:55 +07:00
Adnan Zahir f302bcdb4b codex/fix: show farm stock usage on closing page 2026-04-01 12:31:16 +07:00
Adnan Zahir b68bcc77f5 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: purchase receivement error and recording doesn't show depletion/egg

See merge request mbugroup/lti-web-client!360
2026-04-01 11:10:29 +07:00
Adnan Zahir 3d7a2073b0 formatting 2026-04-01 11:07:05 +07:00
Adnan Zahir 8b1546a305 codex/fix: purchase receivement error and recording doesn't show depletion/egg 2026-04-01 11:03:56 +07:00
Adnan Zahir fa3d0a1179 Merge branch 'codex/sales-at-farm-level' into 'development'
codex: initiated changes (farm-level warehouse stock manipulation on recording and sales)

See merge request mbugroup/lti-web-client!359
2026-04-01 10:27:42 +07:00
Adnan Zahir ffcf422cb5 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!358
2026-04-01 10:15:20 +07:00
Adnan Zahir 8d92da75cf codex: initiated changes 2026-04-01 10:14:05 +07:00
Rivaldi A N S 6b29406307 Merge branch 'fix/purchase-received-product-default-value' into 'development'
[FIX/FE] Purchase

See merge request mbugroup/lti-web-client!357
2026-03-30 07:42:32 +00:00
ValdiANS 4f3e304b2b fix: set received_qty default value to sub_qty 2026-03-30 14:40:26 +07:00
Adnan Zahir 18aff48dc2 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!356
2026-03-27 15:39:56 +07:00
Rivaldi A N S 7bee13124d Merge branch 'fix/list-daily-checklist' into 'development'
[FIX/FE] List Daily Checklist

See merge request mbugroup/lti-web-client!355
2026-03-27 07:42:04 +00:00
ValdiANS f5d9dcbdf6 fix: set DailyChecklist.kandang optional 2026-03-27 11:12:58 +07:00
Rivaldi A N S 04d22e55db Merge branch 'fix/redirect-to-sso' into 'development'
[FIX/FE]: Redirect to SSO

See merge request mbugroup/lti-web-client!354
2026-03-26 09:21:09 +00:00
ValdiANS 8bf5a88edb fix: do not redirect to SSO when the error response status is 401 and API url is refresh API 2026-03-26 16:19:55 +07:00
Rivaldi A N S ee0c47b0a1 Merge branch 'fix/recording' into 'development'
[FIX/FE] Recording Form

See merge request mbugroup/lti-web-client!353
2026-03-17 17:14:28 +00:00
ValdiANS 93d4ff9339 fix: search stock product issue correctly 2026-03-18 00:13:18 +07:00
Adnan Zahir aa52211b0a Merge branch 'development' into 'production'
refactor(FE): Refactor to use `is_laying` instead of

See merge request mbugroup/lti-web-client!352
2026-03-17 13:38:35 +07:00
Rivaldi A N S 39cb38a23f Merge branch 'hotfix/adjustment-recording-fifo-stock' into 'development'
[HOTFIX/FE] Adjustment FIFO Stock V2 (Stock Related Usage)

See merge request mbugroup/lti-web-client!351
2026-03-17 04:38:42 +00:00
rstubryan c1087b37fb chore(FE-prettier): Format code for better readability in inventory
tables
2026-03-16 11:02:25 +07:00
rstubryan c4e27edd56 feat(FE): Add delete functionality to Inventory and Movement tables 2026-03-16 11:01:29 +07:00
rstubryan b020f2b187 refactor(FE): Rename available_qty to transfer_available_qty 2026-03-16 10:51:02 +07:00
rstubryan 375de4c86c refactor(FE): Remove unused dependency from useEffect in RecordingForm 2026-03-16 10:28:36 +07:00
rstubryan 8f361c4327 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into hotfix/adjustment-recording-fifo-stock 2026-03-16 10:10:16 +07:00
Rivaldi A N S 710be88794 Merge branch 'fix/inventory-adjustment' into 'development'
[FIX/FE] Inventory Adjustment

See merge request mbugroup/lti-web-client!350
2026-03-16 02:43:48 +00:00
ValdiANS 0f7a2bd796 fix: adjust formik schema for warehouse 2026-03-16 09:41:23 +07:00
ValdiANS 0c8a833e00 fix: add generic for OptionType type 2026-03-16 09:41:01 +07:00
Rivaldi A N S 0c42bfd70c Merge branch 'fix/inventory-adjustment' into 'development'
[FIX/FE] Inventory Adjustment

See merge request mbugroup/lti-web-client!349
2026-03-14 08:43:04 +00:00
ValdiANS 85dee607e0 fix: get kandang options from project flock kandang instead of kandang master data 2026-03-14 15:42:01 +07:00
rstubryan 6c7e310e67 feat(FE): Add support for available_qty in MovementForm 2026-03-13 13:33:18 +07:00
ragilap 85f6677c2a fixing recording filter form 2026-03-11 15:35:37 +07:00
rstubryan 811850857d refactor(FE): Restrict stock and depletion edits based on permissions 2026-03-11 10:18:19 +07:00
rstubryan 5494cb0ff2 refactor(FE): Remove restriction check for locked rows and add
"isGrowingLocked" badge
2026-03-11 10:03:54 +07:00
rstubryan 11eeac3289 refactor(FE): Refactor payload creation to respect recording
restrictions
2026-03-11 09:56:14 +07:00
ragilap 1621f2ab7d fixing disable field detail 2026-03-10 17:34:52 +07:00
ragilap 5540787154 implement transition recording 2026-03-10 17:05:42 +07:00
ragilap 1b499bc967 implement transition recording 2026-03-10 17:04:44 +07:00
rstubryan 44a5c51023 refactor(FE): Refactor recording restriction logic for clarity and
accuracy
2026-03-10 14:02:07 +07:00
rstubryan aa13e989c1 feat(FE): Add week calculation utility and improve state resets 2026-03-10 11:40:10 +07:00
rstubryan ebe7c367e7 refactor(FE): Refactor isLaying logic for clarity and reuse 2026-03-10 11:34:14 +07:00
rstubryan 2f085c287f Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into hotfix/adjustment-recording-fifo-stock 2026-03-10 11:13:56 +07:00
rstubryan 058f9f403d refactor(FE): Improve delete handlers with success and error feedback 2026-03-10 11:13:46 +07:00
Rivaldi A N S 8b8b7be4b7 Merge branch 'feat/daily-checklist-master-data' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!348
2026-03-09 10:15:55 +00:00
ValdiANS efcecf4f66 fix: implement lazy loading in kandang select input 2026-03-09 17:14:35 +07:00
Rivaldi A N S a6c63a7dcb Merge branch 'feat/daily-checklist-master-data' into 'development'
[FIX/FE]: Daily Checklist Master Data Kandang

See merge request mbugroup/lti-web-client!347
2026-03-09 08:58:50 +00:00
ValdiANS 0263db9fae fix: use DailyChecklistKandangApi instead of KandangApi 2026-03-09 15:55:48 +07:00
rstubryan cc08e3af15 refactor(FE): Fix logical grouping in isLaying and isLayingCategory
checks
2026-03-09 15:04:58 +07:00
rstubryan 0929461ec5 refactor(FE): Improve transition and laying state handling in
RecordingForm
2026-03-09 15:03:42 +07:00
rstubryan ace6633f79 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into hotfix/adjustment-recording-fifo-stock 2026-03-09 14:04:22 +07:00
rstubryan f1a952ca6b refactor(FE): Refactor getRecordingRestrictionInfo for better
readability
2026-03-09 14:01:29 +07:00
rstubryan ed34a99117 refactor(FE): Refactor to use is_laying instead of
`project_flock_category`
2026-03-09 13:59:24 +07:00
Rivaldi A N S 4beaba1f15 Merge branch 'feat/daily-checklist-master-data' into 'development'
[FEAT/FE] Master Data Daily Checklist Kandang

See merge request mbugroup/lti-web-client!346
2026-03-09 06:49:31 +00:00
ValdiANS 8ea029efdd Merge branch 'development' into feat/daily-checklist-master-data 2026-03-09 13:42:01 +07:00
ValdiANS 02e4dba288 feat(FE): implement lazy loading for kandang select input 2026-03-09 13:41:18 +07:00
ValdiANS c42fdbf33d chore(FE): remove unnecessary code 2026-03-09 13:40:58 +07:00
ValdiANS 2cfa8c046b feat(FE): add onScroll prop to SelectPrimitive.Viewport 2026-03-09 13:40:47 +07:00
ValdiANS 30d5516161 fix: use DailyChecklistKandangApi instead of KandangApi 2026-03-09 12:47:12 +07:00
ValdiANS f83abc91da chore(FE): remove unncessary code 2026-03-09 12:30:49 +07:00
ValdiANS 918c51e83b fix(FE): add kandang_group to BaseKandang and add group_id to CreateKandangPayload 2026-03-09 12:30:16 +07:00
ValdiANS f1a4d9b648 feat(FE): create daily checklist kandang types 2026-03-09 12:29:52 +07:00
ValdiANS 29e33560f8 feat(FE): create daily checklist kandang API service 2026-03-09 12:29:36 +07:00
ValdiANS fb9e863862 feat(FE): create MasterKandangContent component 2026-03-09 12:29:20 +07:00
ValdiANS 1b3e5f94f1 feat(FE): add Kandang Group input 2026-03-09 12:29:04 +07:00
ValdiANS e1856926ea feat(FE): add group to kandang form schema 2026-03-09 12:28:48 +07:00
ValdiANS 2b096099d3 feat: add kandang group column 2026-03-09 12:28:29 +07:00
rstubryan ea25417e8d Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into hotfix/adjustment-recording-fifo-stock 2026-03-09 11:39:16 +07:00
kris 668abeca23 Merge branch 'development' into 'production'
ci: switch build images to AWS ECR Public

See merge request mbugroup/lti-web-client!345
2026-03-09 03:16:17 +00:00
M1 AIR deabb1c3ee Merge origin/production into development to resolve MR conflicts 2026-03-09 10:15:20 +07:00
M1 AIR 121c44070c ci: switch build images to AWS ECR Public 2026-03-09 09:45:58 +07:00
ValdiANS 0dbad23cd5 feat(FE): implement options lazy loading by adding onLoadMore and isLoadingMore props 2026-03-09 09:31:05 +07:00
ValdiANS b9a17f472b feat: add daily checklist master data kandang permission in ROUTE_PERMISSIONS 2026-03-09 09:30:26 +07:00
ValdiANS c07b245eeb feat(FE): add Kandang submenu in Daily Checklist Master Data menu 2026-03-09 09:30:04 +07:00
ValdiANS d7e32f8f5b feat(FE): create Daily Checklist Master Data Kandang page 2026-03-09 09:29:22 +07:00
ValdiANS 698fe2e851 feat(FE): add pre-commit script 2026-03-09 09:28:45 +07:00
rstubryan cdf0442a2b refactor(FE): Add transition restrictions for recording operations 2026-03-09 09:04:14 +07:00
Adnan Zahir 422c7c9fb0 Merge branch 'development' into 'production'
refactor(FE): Refactor RowOptionsMenu to use PopoverButton and

See merge request mbugroup/lti-web-client!344
2026-03-09 06:38:03 +07:00
Adnan Zahir 53e018aece Merge branch 'staging' into 'production'
refactor(FE): Add tab state management and skeleton for

See merge request mbugroup/lti-web-client!335
2026-02-26 16:37:04 +07:00
Adnan Zahir ca58e19a48 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!331
2026-02-23 09:32:40 +07:00
Adnan Zahir 0971e6ddeb Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!328
2026-02-20 09:53:46 +07:00
Adnan Zahir 25fbf95062 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!323
2026-02-12 11:06:11 +07:00
Adnan Zahir b2f6c6c485 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!316
2026-02-07 17:11:45 +07:00
Adnan Zahir cc86151631 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!315
2026-02-06 10:39:39 +07:00
Adnan Zahir 755f3fa0bb Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!310
2026-02-05 10:32:18 +07:00
Adnan Zahir ce1114d724 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!295
2026-01-31 10:39:33 +07:00
Adnan Zahir 128b765045 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!294
2026-01-30 17:05:12 +07:00
Adnan Zahir 92c07e7841 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!292
2026-01-30 16:13:35 +07:00
Adnan Zahir 1aba297920 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!291
2026-01-30 15:54:42 +07:00
Adnan Zahir 2aef6522bb Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!286
2026-01-30 13:32:04 +07:00
Adnan Zahir 3bab96c325 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!283
2026-01-30 11:40:25 +07:00
Adnan Zahir 847772616e Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!274
2026-01-28 13:34:05 +07:00
Adnan Zahir 344140e973 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!271
2026-01-28 13:26:27 +07:00
Adnan Zahir 3ce1299091 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!268
2026-01-28 10:07:39 +07:00
Adnan Zahir aea35d4b9f Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!261
2026-01-27 10:36:16 +07:00
Adnan Zahir 5b134148a5 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!258
2026-01-27 09:13:31 +07:00
Adnan Zahir 32f4cf411f Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!254
2026-01-24 14:28:37 +07:00
Adnan Zahir 04d01970aa Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!253
2026-01-24 14:19:24 +07:00
Adnan Zahir 84cbbaf238 Merge branch 'staging' into 'production'
Staging: Transfer To Laying Rework

See merge request mbugroup/lti-web-client!249
2026-01-24 13:06:33 +07:00
Adnan Zahir 9176373072 Merge branch 'development' into 'staging'
Development: Transfer To Laying Rework

See merge request mbugroup/lti-web-client!248
2026-01-24 12:59:23 +07:00
Adnan Zahir 5c50e4a0c1 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!244
2026-01-24 11:25:29 +07:00
Adnan Zahir 7e64ec0f79 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!243
2026-01-24 11:16:16 +07:00
Adnan Zahir e2be39af18 Merge branch 'staging' into 'production'
refactor(FE): Use local state for record date and disable

See merge request mbugroup/lti-web-client!237
2026-01-22 16:20:17 +07:00
Adnan Zahir 9322d6298c Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!236
2026-01-22 16:14:12 +07:00
Adnan Zahir e9cd84e89e Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!229
2026-01-21 15:15:04 +07:00
Adnan Zahir 89cfd31155 Merge branch 'development' into 'staging'
fix(FE): change nominal to absolute value, change form state initial balance,...

See merge request mbugroup/lti-web-client!228
2026-01-21 15:08:55 +07:00
Adnan Zahir ec5962bccc Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!214
2026-01-20 11:52:20 +07:00
Adnan Zahir 0eb4fa99a7 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!215
2026-01-20 11:46:20 +07:00
Adnan Zahir 2ef8b2dc9f Merge branch 'development' into 'staging'
Merge branch 'dev/hotfix/restu' into 'staging'

See merge request mbugroup/lti-web-client!203
2026-01-17 14:36:51 +07:00
Adnan Zahir aed1a1ed01 Merge branch 'development' into 'staging'
Hotfixes flock

See merge request mbugroup/lti-web-client!201
2026-01-17 11:29:55 +07:00
Adnan Zahir 2c9c2660c0 Merge branch 'development' into 'staging'
fix(FE): fix limit fetch data kandang

See merge request mbugroup/lti-web-client!198
2026-01-17 10:41:25 +07:00
Adnan Zahir b840f42ae0 Merge branch 'development' into 'staging'
refactor(FE): Improve vehicle number validation message and set

See merge request mbugroup/lti-web-client!196
2026-01-17 09:05:42 +07:00
Adnan Zahir 6bc86af32f Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!189
2026-01-15 16:21:40 +07:00
kris 1603ae62e0 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!182
2026-01-15 06:55:35 +00:00
kris 8fd442621a Update .gitlab-ci.yml file 2026-01-14 02:52:03 +00:00
kris 35471fc597 Update .gitlab-ci.yml file 2026-01-14 02:29:31 +00:00
M1 AIR bd4242c4fd chore: fix conflict gitlab yml 2026-01-13 15:38:14 +07:00
M1 AIR 56bde974ad chore: gitlab ci yml 2026 01 13 2026-01-13 15:36:56 +07:00
M1 AIR 38258e4311 Merge remote-tracking branch 'origin/development' into staging 2026-01-13 15:27:31 +07:00
kris 149e525ff4 Update .gitlab-ci.yml file 2026-01-10 02:15:18 +00:00
M1 AIR 8fb761f02c Merge remote-tracking branch 'origin/development' into staging 2026-01-09 15:53:47 +07:00
M1 AIR 3bc5a5b75e delete .gitlab 2026-01-09 13:16:42 +07:00
M1 AIR 79112e0da8 Penyesuaian flow repo 2026-01-09 10:52:56 +07:00
M1 AIR bf9eb91ea2 Merge remote-tracking branch 'origin/development' into staging 2026-01-06 19:03:21 +07:00
kris e8c8ffadfe Update .gitlab-ci.yml file 2026-01-03 11:01:19 +00:00
M1 AIR 2ae1c5b382 Merge development into staging (keep staging CI config) 2026-01-03 16:43:49 +07:00
kris 961f81411b Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-web-client!38
2025-12-18 10:30:13 +00:00
Mitra Berlian Unggas de439275e0 Merge branch 'development' into 'staging'
refactor(FE-114): streamline cost field validation messages and enhance layout...

See merge request mbugroup/lti-web-client!37
2025-10-28 08:44:08 +00:00
181 changed files with 14961 additions and 5351 deletions
+6
View File
@@ -45,3 +45,9 @@ next-env.d.ts
# claude # claude
.claude .claude
# rtk
rtk.exe
# local specs
/local-specs
+50 -3
View File
@@ -15,7 +15,7 @@ default:
# ========================================================== # ==========================================================
.build_template: &build_template .build_template: &build_template
stage: build stage: build
image: node:20-alpine image: public.ecr.aws/docker/library/node:20-alpine
cache: cache:
key: npm-cache key: npm-cache
paths: paths:
@@ -30,6 +30,10 @@ default:
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL" - echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_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_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..." - echo "Building Next.js static export..."
- npx next build - npx next build
- | - |
@@ -41,7 +45,11 @@ default:
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", "built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL", "NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_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 EOF
artifacts: artifacts:
@@ -56,7 +64,7 @@ default:
.deploy_template: &deploy_template .deploy_template: &deploy_template
stage: deploy stage: deploy
image: image:
name: amazon/aws-cli:latest name: public.ecr.aws/aws-cli/aws-cli:latest
entrypoint: ['/bin/sh', '-c'] entrypoint: ['/bin/sh', '-c']
script: script:
- set -e - set -e
@@ -142,6 +150,10 @@ build:dev:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id' 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_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' 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:dev:
<<: *deploy_template <<: *deploy_template
@@ -170,6 +182,9 @@ build:staging:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id' 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_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' 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:staging:
<<: *deploy_template <<: *deploy_template
@@ -183,3 +198,35 @@ deploy:staging:
environment: environment:
name: staging name: staging
url: https://stg-lti-erp.mbugroup.id url: https://stg-lti-erp.mbugroup.id
# ==========================================================
# ====== (Branch production) ======
# ==========================================================
build:production:
<<: *build_template
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
environment:
name: staging
variables:
NEXT_PUBLIC_LTI_URL: 'https://lti-erp.mbugroup.id'
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
needs: ['build:production']
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
variables:
S3_BUCKET: 'production-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E1SSLXKYYITASJ'
environment:
name: staging
url: https://lti-erp.mbugroup.id
+2 -1
View File
@@ -1,3 +1,4 @@
npm run format npm run format
npm run lint npm run lint
npx tsc --noEmit npm run typecheck
git add .
+13
View File
@@ -0,0 +1,13 @@
# Project-local RTK filters — commit this file with your repo.
# Filters here override user-global and built-in filters.
# Docs: https://github.com/rtk-ai/rtk#custom-filters
schema_version = 1
# Example: suppress build noise from a custom tool
# [filters.my-tool]
# description = "Compact my-tool output"
# match_command = "^my-tool\\s+build"
# strip_ansi = true
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
# max_lines = 30
# on_empty = "my-tool: ok"
+486
View File
@@ -0,0 +1,486 @@
# 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**
- Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults)
- Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts)
- Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults
- Call `filterModal.closeModal()` at the end
- Attach to form `onReset` handler (not `formik.handleReset`)
```tsx
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) { toast.dismiss(); setDateErrorShown(false); }
formik.resetForm({ values: { start_date: '', end_date: '', customers: [], filterBy: undefined } });
filterModal.closeModal();
};
// ...
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
```
**Optimization: Avoid useCallback and useMemo for trivial operations**
- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child
- Simple derivations and pass-through handlers don't need them:
```tsx
// ✅ Good: plain derivation
const data = isResponseSuccess(response) ? (response.data ?? []) : [];
const meta =
isResponseSuccess(response) && response.meta ? response.meta : null;
// ❌ Avoid: useMemo for trivial conditional access
const data = useMemo(
() => (isResponseSuccess(response) ? (response.data ?? []) : []),
[response]
);
// ✅ Good: simple handler
const handleChange = (val) => setFieldValue('location', val);
// ❌ Avoid: unnecessary useCallback
const handleChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
```
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
**Best practice: Store OptionType objects directly, not IDs**
For select inputs, store the complete `OptionType` object (or `OptionType[]` for multi-select) in both formik state and tableFilterState. `useTableFilter`'s `serializeValue` handles serialization automatically:
- `OptionType<T>` → serialized as `String(value)` in the query string
- `OptionType<T>[]` → serialized as comma-separated values (CSV) — ideal for multi-select API params like `customer_ids`, `sales_ids`
```tsx
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string;
customers: OptionType<number>[]; // multi-select → serializes as CSV
location?: OptionType<string>; // single-select → serializes as value string
filterBy?: OptionType<string>; // single-select radio
}>({
initial: {
search: '',
customers: [],
location: undefined,
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
customers: 'customer_ids', // serializes OptionType[] → "1,2,3"
location: 'location_id', // serializes OptionType → "abc"
filterBy: 'filter_by',
},
persist: true,
storeName: 'my-table',
});
// Initialize formik directly from tableFilterState (no hardcoded defaults)
const formik = useFormik({
initialValues: {
customers: tableFilterState.customers,
location: tableFilterState.location,
filterBy: tableFilterState.filterBy,
},
...
});
// Use formik values directly — no computed helpers needed
<SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} />
<SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} />
<SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} />
```
**Filter field naming convention**
- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations`
- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category`
- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`)
**Filter modal: pass `openModal` directly, never use `enableReinitialize`**
`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway).
```tsx
// ❌ Avoid: enableReinitialize breaks modal mid-interaction
const formik = useFormik({ initialValues: { ... }, enableReinitialize: true });
// ❌ Avoid: unnecessary ref indirection
const handleFilterModalOpenRef = useRef(() => {});
handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); };
// ✅ Correct: pass openModal directly
<ButtonFilter onClick={filterModal.openModal} ... />
```
Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect.
**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/`
- `BalanceMonitoringTab` in `src/components/pages/report/finance/tab/` — multi-select + radio + date range
## SWR fetch pattern
Use `FinanceApi.getAllFetcher` (or the relevant service's `getAllFetcher`) when the result type matches the service generic `T`. When it differs, use `httpClientFetcher` with an explicit type:
```tsx
// ✅ Same type as service generic — use getAllFetcher
const { data } = useSWR(
`${Api.basePath}${getTableFilterQueryString()}`,
Api.getAllFetcher
);
// ✅ Different type — use httpClientFetcher with explicit useSWR type
const { data } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
```
Always name the `toQueryString` alias `getTableFilterQueryString` when destructuring from `useTableFilter`.
## 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).
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (60-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk go test # Go test failures only (90%)
rtk jest # Jest failures only (99.5%)
rtk vitest # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk pytest # Python test failures only (90%)
rtk rake test # Ruby test failures only (90%)
rtk rspec # RSpec test failures only (60%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
| ---------------- | ------------------------------ | --------------- |
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine FROM public.ecr.aws/docker/library/node:20-alpine
RUN apk add --no-cache git bash build-base curl RUN apk add --no-cache git bash build-base curl
+3 -1
View File
@@ -7,8 +7,10 @@
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "next typegen && tsc --noEmit",
"prepare": "husky", "prepare": "husky",
"format": "prettier --write ." "format": "prettier --write .",
"pre-commit": "npm run format && npm run lint && npm run typecheck && npm run build"
}, },
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.1", "@react-pdf/renderer": "^4.3.1",
@@ -0,0 +1,11 @@
import { MasterKandangContent } from '@/figma-make/components/pages/master-data/kandang/MasterKandangContent';
const MasterKandangPage = () => {
return (
<section className='w-full'>
<MasterKandangContent />
</section>
);
};
export default MasterKandangPage;
+2 -2
View File
@@ -15,8 +15,8 @@ const ExpenseDetailPage = () => {
const expenseId = searchParams.get('expenseId'); const expenseId = searchParams.get('expenseId');
const { data: expense, isLoading: isLoadingExpense } = useSWR( const { data: expense, isLoading: isLoadingExpense } = useSWR(
expenseId, ['expense-detail', expenseId],
(id: number) => ExpenseApi.getSingle(id) ([_, id]) => ExpenseApi.getSingle(Number(id))
); );
if (!expenseId) { 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;
@@ -11,10 +11,13 @@ const RecordingEdit = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId'); const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId, recordingDetailKey,
(id: string) => RecordingApi.getSingle(parseInt(id)) ([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
+5 -2
View File
@@ -11,10 +11,13 @@ const RecordingDetail = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId'); const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId, recordingDetailKey,
(id: string) => RecordingApi.getSingle(parseInt(id)) ([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+3 -3
View File
@@ -51,7 +51,7 @@ const Button = ({
return ( return (
<> <>
{!href && ( {(!href || (href && disabled)) && (
<button <button
{...props} {...props}
type={type} type={type}
@@ -68,9 +68,9 @@ const Button = ({
</button> </button>
)} )}
{href && ( {href && !disabled && (
<Link <Link
href={disabled ? '#' : href} href={href}
target={target} target={target}
rel={rel} rel={rel}
aria-disabled={disabled} aria-disabled={disabled}
+1 -1
View File
@@ -226,7 +226,7 @@ const Pagination = ({
const PageInfo = () => ( const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'> <span className='text-nowrap text-sm font-medium text-base-content/50'>
Page {currentPage} of {totalPages} Total Item: {totalItems} | Page {currentPage} of {totalPages}
</span> </span>
); );
+1
View File
@@ -173,6 +173,7 @@ const Table = <TData extends object>({
const tableOptions: TableOptions<TData> = { const tableOptions: TableOptions<TData> = {
columns, columns,
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
defaultColumn: { sortDescFirst: false },
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
+14 -11
View File
@@ -6,6 +6,7 @@ export interface TabItem {
label: ReactNode; label: ReactNode;
content?: ReactNode; content?: ReactNode;
disabled?: boolean; disabled?: boolean;
hide?: boolean;
} }
export interface TabsProps export interface TabsProps
@@ -122,17 +123,19 @@ const Tabs = ({
> >
<div className={getSideContentClasses()}> <div className={getSideContentClasses()}>
<div role='tablist' className={getTabsClasses()}> <div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled }) => ( {tabs.map(({ id, label, disabled, hide }) =>
<button hide ? null : (
key={id} <button
role='tab' key={id}
className={getTabClasses(id === activeTabId, disabled)} role='tab'
onClick={() => !disabled && handleTabChange(id)} className={getTabClasses(id === activeTabId, disabled)}
disabled={disabled} onClick={() => !disabled && handleTabChange(id)}
> disabled={disabled}
{label} >
</button> {label}
))} </button>
)
)}
</div> </div>
{sideContent && sideContent} {sideContent && sideContent}
</div> </div>
+3 -1
View File
@@ -35,7 +35,9 @@ const NumberInput = ({
| undefined; | undefined;
if (newChangeEvent) { if (newChangeEvent) {
newChangeEvent.target.value = numberFormatValues.value; newChangeEvent.target.value = parseFloat(
numberFormatValues.value
) as unknown as string;
onChange?.(newChangeEvent); onChange?.(newChangeEvent);
} }
+25 -17
View File
@@ -24,8 +24,8 @@ import {
} from '@/types/api/api-general'; } from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType { export interface OptionType<T = string | number> {
value: string | number; value: T;
label: string; label: string;
className?: string; className?: string;
labelClassName?: string; labelClassName?: string;
@@ -523,7 +523,7 @@ const useSelect = <T,>(
const qs = new URLSearchParams({ const qs = new URLSearchParams({
...(params ?? {}), ...(params ?? {}),
[searchKey]: inputValue ?? '', [searchKey ? searchKey : 'search']: inputValue ?? '',
[pageKey]: String(pageIndex + 1), [pageKey]: String(pageIndex + 1),
[limitKey]: String(limit), [limitKey]: String(limit),
}).toString(); }).toString();
@@ -566,23 +566,31 @@ const useSelect = <T,>(
setSize(size + 1); setSize(size + 1);
}; };
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0; const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
if (isResponseSuccess(pages?.[latestPagesIndex])) { const { formattedSuccessRawData, formattedErrorRawData } = useMemo(() => {
formattedSuccessRawData = { let successData: SuccessApiResponse<T[]> | undefined = undefined;
...pages?.[latestPagesIndex], let errorData: ErrorApiResponse | undefined = undefined;
data:
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
[],
};
}
if (isResponseError(pages?.[latestPagesIndex])) { if (isResponseSuccess(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex]; successData = {
} ...pages![latestPagesIndex],
data:
pages?.flatMap((page) =>
isResponseSuccess(page) ? page.data : []
) ?? [],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
errorData = pages![latestPagesIndex];
}
return {
formattedSuccessRawData: successData,
formattedErrorRawData: errorData,
};
}, [pages, latestPagesIndex]);
return { return {
inputValue, inputValue,
@@ -69,6 +69,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
secondaryButton={ secondaryButton={
secondaryButton secondaryButton
? { ? {
...secondaryButton,
text: secondaryButton?.text ?? 'Tidak', text: secondaryButton?.text ?? 'Tidak',
onClick: (e) => { onClick: (e) => {
if (secondaryButton && secondaryButton?.onClick) { if (secondaryButton && secondaryButton?.onClick) {
@@ -112,12 +112,11 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
kandangData={kandangData} kandangData={kandangData}
/> />
{!kandangData && ( <ClosingKandangList
<ClosingKandangList initialValue={initialValue}
initialValue={initialValue} projectData={projectData}
projectData={projectData} selectedKandangId={kandangData?.id}
/> />
)}
<Tabs <Tabs
activeTabId={activeTabId} activeTabId={activeTabId}
@@ -5,9 +5,11 @@ import { ProjectFlock } from '@/types/api/production/project-flock';
const ClosingKandangList = ({ const ClosingKandangList = ({
initialValue, initialValue,
projectData, projectData,
selectedKandangId,
}: { }: {
initialValue?: ClosingGeneralInformation; initialValue?: ClosingGeneralInformation;
projectData?: ProjectFlock; projectData?: ProjectFlock;
selectedKandangId?: number;
}) => { }) => {
return ( return (
<div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'> <div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'>
@@ -22,6 +24,9 @@ const ClosingKandangList = ({
variant='outline' variant='outline'
className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm' className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm'
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`} href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
disabled={
selectedKandangId === kandang.project_flock_kandang_id
}
> >
{kandang.name} {kandang.name}
</Button> </Button>
@@ -276,7 +276,7 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
{ {
id: 'kandang', id: 'kandang',
accessorKey: 'kandang', accessorKey: 'kandang',
header: 'Kandang', header: 'Kandang Atribusi',
cell: (props) => { cell: (props) => {
const kandang = props.getValue() as Kandang; const kandang = props.getValue() as Kandang;
return kandang?.name || '-'; return kandang?.name || '-';
@@ -127,11 +127,11 @@ const ClosingOutgoingSapronaksTable = ({
}, },
{ {
accessorKey: 'source_warehouse', accessorKey: 'source_warehouse',
header: 'Gudang Asal', header: 'Gudang Asal (Fisik)',
}, },
{ {
accessorKey: 'destination_warehouse', accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan', header: 'Gudang Tujuan (Fisik)',
}, },
{ {
accessorKey: 'quantity', accessorKey: 'quantity',
@@ -9,8 +9,11 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard'; import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { ProjectFlockApi } from '@/services/api/production'; import {
import { KandangApi, LocationApi } from '@/services/api/master-data'; ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import { LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF'; import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import { import {
DashboardFilterType, DashboardFilterType,
@@ -22,10 +25,7 @@ import DashboardExportCharts, {
DashboardExportChartsRef, DashboardExportChartsRef,
} from '@/components/pages/dashboard/export/DashboardExportCharts'; } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput'; import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { import { DashboardMeta } from '@/types/api/dashboard/dashboard';
DashboardFilter,
DashboardMeta,
} from '@/types/api/dashboard/dashboard';
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats'; import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
@@ -42,6 +42,8 @@ import { cn } from '@/lib/helper';
import DashboardExportStats, { import DashboardExportStats, {
DashboardExportStatsRef, DashboardExportStatsRef,
} from '@/components/pages/dashboard/export/DashboardExportStats'; } from '@/components/pages/dashboard/export/DashboardExportStats';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
// Helper function to normalize values to array // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -68,7 +70,6 @@ const DashboardProduction = () => {
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>( const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW' (filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
); );
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>( const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
normalizeToArray(filterValues.location) normalizeToArray(filterValues.location)
); );
@@ -80,9 +81,29 @@ const DashboardProduction = () => {
const { const {
data: dashboardProductionResponse, data: dashboardProductionResponse,
isLoading: isLoadingDashboardProductionData, isLoading: isLoadingDashboardProductionData,
mutate: refreshDashboardProductionData, } = useSWR(
} = useSWR(endpointUrl, () => [
DashboardApi.getDashboardProductionFetcher(endpointUrl) 'dashboard-production',
filterValues.startDate ?? '',
filterValues.endDate ?? '',
filterValues.analysisMode ?? 'OVERVIEW',
normalizeToArray(filterValues.location).toString(),
normalizeToArray(filterValues.flock).toString(),
normalizeToArray(filterValues.kandang).toString(),
filterValues.comparisonType ?? '',
],
() =>
DashboardApi.getDashboardProductionFetcher({
start_date: filterValues.startDate || '',
end_date: filterValues.endDate || '',
analysis_mode:
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') ||
'OVERVIEW',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType || '',
})
); );
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse) const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
@@ -95,23 +116,23 @@ const DashboardProduction = () => {
options: flockOptions, options: flockOptions,
isLoadingOptions: isLoadingFlockOptions, isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock, loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', { } = useSelect<ProjectFlock>(
location_id: selectedLocationIds ? selectedLocationIds.toString() : '', ProjectFlockApi.basePath,
}); 'id',
'flock_name',
'search',
{
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
}
);
const { const {
setInputValue: setInputValueLocation, setInputValue: setInputValueLocation,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation, loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name'); } = useSelect(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [ const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' }, { value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' }, { value: 'FLOCK', label: 'Flock' },
@@ -135,68 +156,43 @@ const DashboardProduction = () => {
enableReinitialize: true, enableReinitialize: true,
validationSchema: getDashboardFilterSchema(analysisMode), validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => { onSubmit: (values) => {
// Save filter values to store
setFilterValues(values); setFilterValues(values);
filterModal.closeModal();
handleApplyFilter({
start_date: values.startDate || '',
end_date: values.endDate || '',
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(values.location),
flock_ids: normalizeToArray(values.flock),
kandang_ids: normalizeToArray(values.kandang),
comparison_type: values.comparisonType,
});
}, },
}); });
const { resetForm } = formik; const { resetForm } = formik;
const selectedLocationValues = normalizeToArray(formik.values.location);
const selectedFlockValues = normalizeToArray(formik.values.flock);
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'kandang_id',
'kandang.name',
'search',
{
location_id:
selectedLocationValues.length > 0
? selectedLocationValues.toString()
: '',
project_flock_id:
selectedFlockValues.length > 0 ? selectedFlockValues.toString() : '',
}
);
const handleResetFilter = useCallback(() => { const handleResetFilter = useCallback(() => {
resetForm(); resetForm();
resetFilterValues(); // Clear stored filter values resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW'); setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards');
setSelectedLocationIds([]); setSelectedLocationIds([]);
}, [resetForm, resetFilterValues]); filterModal.closeModal();
}, [filterModal, resetForm, resetFilterValues]);
const handleApplyFilter = useCallback(
(values: DashboardFilter) => {
// Build query params object, only include non-empty values
const params: Record<string, string> = {};
if (values.start_date) params.start_date = values.start_date;
if (values.end_date) params.end_date = values.end_date;
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
if (values.location_ids.length > 0)
params.location_ids = values.location_ids.toString();
if (values.flock_ids.length > 0)
params.flock_ids = values.flock_ids.toString();
if (values.kandang_ids.length > 0)
params.kandang_ids = values.kandang_ids.toString();
if (values.comparison_type)
params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
filterModal.closeModal();
refreshDashboardProductionData();
},
[filterModal, refreshDashboardProductionData]
);
// ===== Load filter from store on mount =====
useEffect(() => {
if (!filterValues) return;
handleApplyFilter({
start_date: filterValues.startDate,
end_date: filterValues.endDate,
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType,
});
}, [filterValues, handleApplyFilter]);
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
@@ -268,14 +264,6 @@ const DashboardProduction = () => {
}; };
}, [clearNavbarActions]); }, [clearNavbarActions]);
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
);
}
return ( return (
<> <>
<section className='w-full p-3 space-y-3'> <section className='w-full p-3 space-y-3'>
@@ -327,9 +315,15 @@ const DashboardProduction = () => {
</div> </div>
{/* Dashboard Stats */} {/* Dashboard Stats */}
<div> <div>
<DashboardStats {isLoadingDashboardProductionData ? (
data={dashboardProductionData?.statistics_data ?? []} <div className='w-full min-h-screen flex items-center justify-center'>
/> <span className='loading loading-spinner loading-xl'></span>
</div>
) : (
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
)}
</div> </div>
{/* Use DashboardLineChart component or skeleton */} {/* Use DashboardLineChart component or skeleton */}
@@ -537,6 +531,7 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
@@ -573,6 +568,7 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -604,6 +600,7 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
@@ -643,6 +640,7 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -669,6 +667,7 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
</> </>
@@ -707,6 +706,7 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -733,6 +733,7 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
</> </>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
@@ -15,6 +16,7 @@ interface ExpenseDetailProps {
} }
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => { const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter();
const [activeTab, setActiveTab] = useState<string>('request'); const [activeTab, setActiveTab] = useState<string>('request');
const expenseDetailTabs = useMemo(() => { const expenseDetailTabs = useMemo(() => {
@@ -46,8 +48,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
<section className='w-full max-w-full pb-16'> <section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense'
variant='link' variant='link'
onClick={router.back}
className='w-fit p-0 text-primary' className='w-fit p-0 text-primary'
> >
<Icon icon='uil:arrow-left' width={24} height={24} /> <Icon icon='uil:arrow-left' width={24} height={24} />
@@ -1,5 +1,8 @@
'use client';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -16,6 +19,7 @@ import {
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant'; import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
interface ExpenseRealizationContentProps { interface ExpenseRealizationContentProps {
@@ -25,6 +29,8 @@ interface ExpenseRealizationContentProps {
const ExpenseRealizationContent = ({ const ExpenseRealizationContent = ({
initialValues, initialValues,
}: ExpenseRealizationContentProps) => { }: ExpenseRealizationContentProps) => {
const searchParams = useSearchParams();
const formik = useFormik<UploadRequestDocumentsFormValues>({ const formik = useFormik<UploadRequestDocumentsFormValues>({
initialValues: { initialValues: {
documents: [], documents: [],
@@ -74,7 +80,11 @@ const ExpenseRealizationContent = ({
<Button <Button
type='button' type='button'
color='warning' color='warning'
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`} href={buildExpenseActionHref(
'/expense/realization/edit/',
initialValues?.id as number,
searchParams
)}
className='px-4 grow sm:grow-0' className='px-4 grow sm:grow-0'
> >
<Icon icon='mdi:pencil-outline' width={24} height={24} /> <Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useSWRConfig } from 'swr';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -19,6 +20,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton'; import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper'; import { formatCurrency, formatDate } from '@/lib/helper';
@@ -26,11 +28,15 @@ import {
UploadRequestDocumentsFormSchema, UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues, UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } 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 { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line'; import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import {
buildExpenseActionHref,
getExpenseListReturnTo,
} from '@/lib/expense-list-navigation';
interface ExpenseRequestContentProps { interface ExpenseRequestContentProps {
initialValues?: Expense; initialValues?: Expense;
@@ -40,6 +46,13 @@ const ExpenseRequestContent = ({
initialValues, initialValues,
}: ExpenseRequestContentProps) => { }: ExpenseRequestContentProps) => {
const router = useRouter(); 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 } = const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
useApprovalSteps({ useApprovalSteps({
@@ -89,17 +102,24 @@ const ExpenseRequestContent = ({
!isLatestApprovalRejected && !isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 4; initialValues?.latest_approval.step_number === 4;
const isExpensePaidOff = initialValues?.is_paid;
const showPaidOffButton =
!isExpensePaidOff && (initialValues?.latest_approval.step_number ?? 0) >= 4;
// Modal hooks // Modal hooks
const deleteModal = useModal(); const deleteModal = useModal();
const completeModal = useModal(); const completeModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
const rejectModal = useModal(); const rejectModal = useModal();
const paidOffModal = useModal();
// Modal loading state // Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false); const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
const [, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const formik = useFormik<UploadRequestDocumentsFormValues>({ const formik = useFormik<UploadRequestDocumentsFormValues>({
@@ -140,7 +160,31 @@ const ExpenseRequestContent = ({
rejectModal.openModal(); rejectModal.openModal();
}; };
const paidOffClickHandler = () => {
paidOffModal.openModal();
};
// Modal confirm click handler // 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 () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -148,7 +192,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(deleteResponse)) { if (isResponseSuccess(deleteResponse)) {
toast.success('Berhasil menghapus data biaya operasional!'); toast.success('Berhasil menghapus data biaya operasional!');
router.push('/expense'); router.push(returnTo);
} else { } else {
toast.error('Gagal menghapus data biaya operasional!'); toast.error('Gagal menghapus data biaya operasional!');
} }
@@ -164,7 +208,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(completeRes)) { if (isResponseSuccess(completeRes)) {
toast.success(completeRes.message); toast.success(completeRes.message);
router.push('/expense'); router.push(returnTo);
} else { } else {
toast.error(completeRes?.message as string); toast.error(completeRes?.message as string);
} }
@@ -204,7 +248,7 @@ const ExpenseRequestContent = ({
toast.success(approveResponse?.message); toast.success(approveResponse?.message);
setApprovalNotes(''); setApprovalNotes('');
router.push('/expense'); router.push(returnTo);
} else { } else {
approveModal.closeModal(); approveModal.closeModal();
@@ -239,7 +283,7 @@ const ExpenseRequestContent = ({
toast.success(rejectResponse.message); toast.success(rejectResponse.message);
setApprovalNotes(''); setApprovalNotes('');
router.push('/expense'); router.push(returnTo);
} else { } else {
rejectModal.closeModal(); rejectModal.closeModal();
@@ -279,8 +323,6 @@ const ExpenseRequestContent = ({
)} )}
<div className='w-full mt-4 flex flex-col gap-4'> <div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */}
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && ( {isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'> <RequirePermission permissions='lti.expense.approve.head_area'>
@@ -367,7 +409,11 @@ const ExpenseRequestContent = ({
<Button <Button
variant='outline' variant='outline'
color='info' color='info'
href={`/expense/realization/?expenseId=${initialValues?.id}`} href={buildExpenseActionHref(
'/expense/realization/',
initialValues?.id as number,
searchParams
)}
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon <Icon
@@ -380,13 +426,35 @@ const ExpenseRequestContent = ({
</RequirePermission> </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'> <div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{showEditButton && ( {showEditButton && (
<RequirePermission permissions='lti.expense.update'> <RequirePermission permissions='lti.expense.update'>
<Button <Button
type='button' type='button'
color='warning' color='warning'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`} href={buildExpenseActionHref(
'/expense/detail/edit/',
initialValues?.id as number,
searchParams
)}
className='px-4 grow sm:grow-0' className='px-4 grow sm:grow-0'
> >
<Icon icon='mdi:pencil-outline' width={24} height={24} /> <Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -521,6 +589,19 @@ const ExpenseRequestContent = ({
/> />
</td> </td>
</tr> </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> <tr>
<th>Dokumen Pengajuan</th> <th>Dokumen Pengajuan</th>
<th>:</th> <th>:</th>
@@ -536,21 +617,15 @@ const ExpenseRequestContent = ({
<ul className='list-disc'> <ul className='list-disc'>
{initialValues?.documents.map( {initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => { (requestDocument, requestDocumentIdx) => {
const path = requestDocument.path.startsWith(
'/'
)
? requestDocument.path.slice(1)
: requestDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return ( return (
<li key={requestDocumentIdx}> <li key={requestDocumentIdx}>
<Link <Link
href={documentUrl} href={requestDocument.path}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-blue-500 underline' className='text-blue-500 underline'
> >
{requestDocument.path}{' '} {requestDocument.name}{' '}
<Icon <Icon
icon='cuida:open-in-new-tab-outline' icon='cuida:open-in-new-tab-outline'
width={12} width={12}
@@ -746,6 +821,21 @@ const ExpenseRequestContent = ({
onClick: confirmationModalRejectClickHandler, 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 = { export type ExpensesFilterType = {
transaction_date: string | null; transaction_date: string | null;
realization_date: string | null; realization_date: string | null;
location_id: string | null; location: { value: number; label: string } | null;
vendor_id: 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({ export const ExpensesFilterSchema = yup.object({
transaction_date: yup.string().nullable(), transaction_date: yup.string().nullable(),
realization_date: yup realization_date: yup.string().nullable(),
.string() location: yup
.nullable() .object({
.test( value: yup.number().required(),
'is-greater-or-equal-transaction', label: yup.string().required(),
'Tanggal realisasi tidak boleh sebelum tanggal transaksi', })
function (value) { .nullable(),
const { transaction_date } = this.parent; vendor: yup
if (!transaction_date || !value) return true; .object({
return new Date(value) >= new Date(transaction_date); value: yup.number().required(),
} label: yup.string().required(),
), })
location_id: yup.string().nullable(), .nullable(),
vendor_id: yup.string().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>; export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { RefObject } from 'react'; import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -11,8 +11,11 @@ import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
import { import {
ExpensesFilterSchema, ExpensesFilterSchema,
ExpensesFilterValues, ExpensesFilterValues,
@@ -31,64 +34,143 @@ const ExpensesFilterModal = ({
onSubmit, onSubmit,
onReset, onReset,
}: ExpensesFilterModalProps) => { }: ExpensesFilterModalProps) => {
const [selectedLocationId, setSelectedLocationId] = useState<string>(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const closeModalHandler = () => { const closeModalHandler = () => {
ref.current?.close(); 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 { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { const {
setInputValue: setVendorInputValue, setInputValue: setVendorInputValue,
options: vendorOptions, options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions, isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreVendors,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = 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>({ const formik = useFormik<ExpensesFilterValues>({
enableReinitialize: true,
initialValues: initialValues || { initialValues: initialValues || {
transaction_date: null, transaction_date: null,
realization_date: null, realization_date: null,
location_id: null, location: null,
vendor_id: null, vendor: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
}, },
validationSchema: ExpensesFilterSchema, validationSchema: ExpensesFilterSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
onSubmit?.(values); onSubmit?.(values);
closeModalHandler(); closeModalHandler();
}, },
onReset: () => {
onReset?.();
closeModalHandler();
},
}); });
const locationValue = formik.values.location_id useEffect(() => {
? locationOptions.find( setSelectedLocationId(
(opt) => String(opt.value) === formik.values.location_id initialValues?.location?.value ? String(initialValues.location.value) : ''
) || null );
: null; }, [initialValues?.location]);
const vendorValue = formik.values.vendor_id const { resetForm } = formik;
? vendorOptions.find(
(opt) => String(opt.value) === formik.values.vendor_id const formikResetHandler = useCallback(() => {
) || null resetForm({
: null; 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 locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId = const value = val as OptionType | null;
val && !Array.isArray(val) ? (String(val.value) as string) : null; formik.setFieldValue('location', value);
formik.setFieldValue('location_id', locationId); formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(value?.value ? String(value.value) : '');
}; };
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => { const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
const vendorId = formik.setFieldValue('vendor', val as OptionType | null);
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('vendor_id', vendorId);
}; };
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 ( return (
<Modal <Modal
ref={ref} ref={ref}
@@ -98,7 +180,7 @@ const ExpensesFilterModal = ({
> >
<form <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formikResetHandler}
className='w-full flex flex-col' className='w-full flex flex-col'
> >
{/* Modal Header */} {/* Modal Header */}
@@ -121,49 +203,41 @@ const ExpensesFilterModal = ({
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'> <DateInput
<span className='py-2 text-xs font-semibold'>Tanggal</span> name='transaction_date'
<div className='flex flex-row items-center gap-1.5'> label='Tanggal Transaksi'
<DateInput placeholder='Tanggal Transaksi'
name='transaction_date' value={formik.values.transaction_date || ''}
placeholder='Tanggal Transaksi' onChange={formik.handleChange}
value={formik.values.transaction_date || ''} onBlur={formik.handleBlur}
onChange={formik.handleChange} isError={
onBlur={formik.handleBlur} formik.touched.transaction_date &&
isError={ !!formik.errors.transaction_date
formik.touched.transaction_date && }
!!formik.errors.transaction_date />
}
/> <DateInput
<hr className='w-full max-w-3 h-px border-base-content/10' /> name='realization_date'
<DateInput label='Tanggal Realisasi'
name='realization_date' placeholder='Tanggal Realisasi'
placeholder='Tanggal Realisasi' value={formik.values.realization_date || ''}
value={formik.values.realization_date || ''} onChange={formik.handleChange}
onChange={formik.handleChange} onBlur={formik.handleBlur}
onBlur={formik.handleBlur} isError={
isError={ formik.touched.realization_date &&
formik.touched.realization_date && !!formik.errors.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>
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
placeholder='Pilih Lokasi' placeholder='Pilih Lokasi'
options={locationOptions} options={locationOptions}
value={locationValue} value={formik.values.location}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
isSearchable={true} isSearchable={true}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
@@ -173,14 +247,87 @@ const ExpensesFilterModal = ({
label='Vendor' label='Vendor'
placeholder='Pilih Vendor' placeholder='Pilih Vendor'
options={vendorOptions} options={vendorOptions}
value={vendorValue} value={formik.values.vendor}
onChange={vendorChangeHandler} onChange={vendorChangeHandler}
onInputChange={setVendorInputValue} onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions} isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreVendors}
isClearable isClearable
isSearchable={true} isSearchable={true}
className={{ wrapper: 'w-full' }} 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> </div>
{/* Modal Footer */} {/* Modal Footer */}
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -35,6 +35,7 @@ import { isResponseError } from '@/lib/api-helper';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { ACCEPTED_FILE_TYPE } from '@/config/constant'; import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
@@ -48,6 +49,8 @@ const ExpenseRealizationForm = ({
initialValues, initialValues,
}: ExpenseRealizationFormProps) => { }: ExpenseRealizationFormProps) => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState(''); const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
@@ -64,9 +67,9 @@ const ExpenseRealizationForm = ({
} }
toast.success(createExpenseRes?.message as string); toast.success(createExpenseRes?.message as string);
router.push('/expense'); router.push(returnTo);
}, },
[router] [initialValues?.id, returnTo, router]
); );
const updateExpenseHandler = useCallback( const updateExpenseHandler = useCallback(
@@ -83,9 +86,9 @@ const ExpenseRealizationForm = ({
toast.success(updateExpenseRes?.message as string); toast.success(updateExpenseRes?.message as string);
router.refresh(); router.refresh();
router.push('/expense'); router.push(returnTo);
}, },
[router] [returnTo, router]
); );
const formik = useFormik<ExpenseRealizationFormValues>({ const formik = useFormik<ExpenseRealizationFormValues>({
@@ -207,7 +210,7 @@ const ExpenseRealizationForm = ({
// add new realizations for each kandang // add new realizations for each kandang
kandangs.forEach((kandangItem) => { kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return; if (isNaN(Number(kandangItem.id))) return;
const existingRealization = formik.values.realizations?.find( const existingRealization = formik.values.realizations?.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id (realizationItem) => realizationItem.kandang_id === kandangItem.id
@@ -258,7 +261,7 @@ const ExpenseRealizationForm = ({
<section className='w-full'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href={returnTo}
variant='link' variant='link'
className='w-fit p-0 text-primary' className='w-fit p-0 text-primary'
> >
@@ -35,6 +35,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
setInputValue: setNonstockInputValue, setInputValue: setNonstockInputValue,
options: nonstockOptions, options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions, isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>( } = useSelect<Nonstock>(
NonstockApi.basePath, NonstockApi.basePath,
'id', 'id',
@@ -164,6 +165,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
options={nonstockOptions} options={nonstockOptions}
isLoading={isLoadingNonstockOptions} isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue} onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
className={{ wrapper: 'min-w-48' }} className={{ wrapper: 'min-w-48' }}
isDisabled isDisabled
/> />
@@ -178,12 +178,14 @@ const ExpenseRequestForm = ({
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { const {
setInputValue: setVendorInputValue, setInputValue: setVendorInputValue,
options: supplierOptions, options: supplierOptions,
isLoadingOptions: isLoadingVendorOptions, isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -408,6 +410,7 @@ const ExpenseRequestForm = ({
options={locationOptions} options={locationOptions}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isError={ isError={
formik.touched.location_id && Boolean(formik.errors.location_id) formik.touched.location_id && Boolean(formik.errors.location_id)
} }
@@ -452,6 +455,7 @@ const ExpenseRequestForm = ({
options={supplierOptions} options={supplierOptions}
onInputChange={setVendorInputValue} onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions} isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreSuppliers}
isError={ isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id) formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
} }
@@ -287,8 +287,8 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
PT LUMBUNG TELUR INDONESIA PT LUMBUNG TELUR INDONESIA
</Text> </Text>
<Text style={ExpensePDFStyle.companyAddress}> <Text style={ExpensePDFStyle.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Cipedes, Kec. Sukajadi, Kota Bandung 40162 Bandung Barat, Jawa Barat 40514
</Text> </Text>
<View style={ExpensePDFStyle.doubleDivider} /> <View style={ExpensePDFStyle.doubleDivider} />
+256 -149
View File
@@ -1,13 +1,12 @@
'use client'; 'use client';
import React, { import React, { useEffect, useMemo, useState } from 'react';
useCallback, import {
useEffect, CellContext,
useMemo, ColumnDef,
useRef, SortingState,
useState, Updater,
} from 'react'; } from '@tanstack/react-table';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -30,7 +29,7 @@ import {
FINANCE_TRANSACTION_TYPE_OPTIONS, FINANCE_TRANSACTION_TYPE_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper'; import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
@@ -39,7 +38,8 @@ import PopoverContent from '@/components/popover/PopoverContent';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useUiStore } from '@/stores/ui/ui.store'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/dropdown/Dropdown';
import { import {
FinanceTableFilterSchema, FinanceTableFilterSchema,
FinanceTableFilterValues, FinanceTableFilterValues,
@@ -176,9 +176,6 @@ const RowOptionsMenu = ({
}; };
const FinanceTable = () => { const FinanceTable = () => {
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
const previousPathRef = useRef<string | null>(null);
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -187,14 +184,18 @@ const FinanceTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: searchValue, search: '',
transactionTypes: '', transactionTypes: '',
bankIds: '', bankIds: '',
customerIds: '', customerIds: '',
supplierIds: '', supplierIds: '',
sortBy: '', sort_by: '',
orderBy: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
bankNames: '',
customerNames: '',
supplierNames: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -203,10 +204,14 @@ const FinanceTable = () => {
bankIds: 'bank_ids', bankIds: 'bank_ids',
customerIds: 'customer_ids', customerIds: 'customer_ids',
supplierIds: 'supplier_ids', supplierIds: 'supplier_ids',
sortBy: 'sort_date', sort_by: 'sort_by',
orderBy: 'sort_order',
startDate: 'start_date', startDate: 'start_date',
endDate: 'end_date', endDate: 'end_date',
}, },
excludeKeysFromUrl: ['bankNames', 'customerNames', 'supplierNames'],
persist: true,
storeName: 'finance-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -229,13 +234,14 @@ const FinanceTable = () => {
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null); const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null); const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isExportLoading, setIsExportLoading] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
// ===== Formik for Filter ===== // ===== Formik for Filter =====
const filterFormik = useFormik<FinanceTableFilterValues>({ const filterFormik = useFormik<FinanceTableFilterValues>({
initialValues: { initialValues: {
search: searchValue, search: tableFilterState.search || '',
transaction_types: '', transaction_types: '',
bank_ids: '', bank_ids: '',
customer_ids: '', customer_ids: '',
@@ -245,29 +251,48 @@ const FinanceTable = () => {
end_date: '', end_date: '',
}, },
validationSchema: FinanceTableFilterSchema, validationSchema: FinanceTableFilterSchema,
enableReinitialize: true, onSubmit: (values, { setSubmitting }) => {
onSubmit: (values) => { updateFilter('search', values.search, true);
updateFilter('search', values.search); updateFilter('transactionTypes', values.transaction_types, true);
setSearchValue(values.search); updateFilter('bankIds', values.bank_ids, true);
updateFilter('transactionTypes', values.transaction_types); updateFilter('customerIds', values.customer_ids, true);
updateFilter('bankIds', values.bank_ids); updateFilter('supplierIds', values.supplier_ids, true);
updateFilter('customerIds', values.customer_ids); updateFilter('sort_by', values.sort_by, true);
updateFilter('supplierIds', values.supplier_ids); updateFilter('startDate', values.start_date, true);
updateFilter('sortBy', values.sort_by); updateFilter('endDate', values.end_date, true);
updateFilter('startDate', values.start_date); // Save display names for restoration on modal reopen
updateFilter('endDate', values.end_date); 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(); filterModal.closeModal();
setSubmitting(false);
}, },
onReset: () => { onReset: () => {
updateFilter('search', ''); setSelectedTransactionType(null);
resetSearchValue(); setSelectedBank(null);
updateFilter('transactionTypes', ''); setSelectedCustomerId(null);
updateFilter('bankIds', ''); setSelectedSupplierId(null);
updateFilter('customerIds', ''); setSelectedSortBy(null);
updateFilter('supplierIds', ''); updateFilter('search', '', true);
updateFilter('sortBy', ''); updateFilter('transactionTypes', '', true);
updateFilter('startDate', ''); updateFilter('bankIds', '', true);
updateFilter('endDate', ''); 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 +345,10 @@ const FinanceTable = () => {
}); });
}, [bankOptions, bankRawData]); }, [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 ===== // ===== Handler =====
const searchChangeHandler = useCallback( const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
(e: React.ChangeEvent<HTMLInputElement>) => { updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value); };
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const transactionTypeChangeHandler = ( const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
@@ -409,6 +404,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 startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
const endDate = filterFormik.values.end_date; const endDate = filterFormik.values.end_date;
@@ -469,28 +484,88 @@ const FinanceTable = () => {
}; };
const handleFilterModalOpen = () => { 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(); filterModal.openModal();
filterFormik.validateForm();
}; };
const resetFilterHandler = () => { const exportToExcel = async () => {
setSelectedTransactionType(null); setIsExportLoading(true);
setSelectedBank(null); try {
setSelectedCustomerId(null); await FinanceApi.exportToExcel(getTableFilterQueryString());
setSelectedSupplierId(null); toast.success('Excel berhasil dibuat dan diunduh.');
setSelectedSortBy(null); } catch (error) {
toast.error(
filterFormik.resetForm(); await getErrorMessage(error, 'Gagal mengekspor data finance.')
);
updateFilter('search', ''); } finally {
resetSearchValue(); setIsExportLoading(false);
updateFilter('transactionTypes', ''); }
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -509,10 +584,12 @@ const FinanceTable = () => {
{ {
header: 'ID', header: 'ID',
accessorKey: 'payment_code', accessorKey: 'payment_code',
enableSorting: true,
}, },
{ {
header: 'References Number', header: 'References Number',
accessorKey: 'reference_number', accessorKey: 'reference_number',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.reference_number; const value = props.row.original.reference_number;
return <span>{value ?? '-'}</span>; return <span>{value ?? '-'}</span>;
@@ -521,6 +598,7 @@ const FinanceTable = () => {
{ {
header: 'Jenis Transaksi', header: 'Jenis Transaksi',
accessorKey: 'transaction_type', accessorKey: 'transaction_type',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.transaction_type const value = props.row.original.transaction_type
.split('_') .split('_')
@@ -530,7 +608,8 @@ const FinanceTable = () => {
}, },
{ {
header: 'Pihak', header: 'Pihak',
accessorFn: (finance: Finance) => finance.party?.name, accessorKey: 'customer_name',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party?.id) { if (props.row.original.party?.id) {
return <span>{props.row.original.party?.name}</span>; return <span>{props.row.original.party?.name}</span>;
@@ -539,13 +618,23 @@ const FinanceTable = () => {
}, },
}, },
{ {
header: 'Tanggal', header: 'Tanggal Pembayaran',
accessorFn: (finance: Finance) => accessorKey: 'payment_date',
formatDate(finance.payment_date, 'DD MMM YYYY'), 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', header: 'Metode Pembayaran',
accessorKey: 'payment_method', accessorKey: 'payment_method',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.payment_method.split('_').join(' '); const value = props.row.original.payment_method.split('_').join(' ');
return <span>{formatTitleCase(value)}</span>; return <span>{formatTitleCase(value)}</span>;
@@ -553,20 +642,26 @@ const FinanceTable = () => {
}, },
{ {
header: 'Bank', header: 'Bank',
accessorFn: (finance: Finance) => accessorKey: 'bank',
finance.bank enableSorting: true,
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}` 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)', header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) => accessorKey: 'expense_amount',
formatCurrency(Math.abs(finance.expense_amount)), enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.expense_amount)),
}, },
{ {
header: 'Pemasukan (Rp)', header: 'Pemasukan (Rp)',
accessorFn: (finance: Finance) => accessorKey: 'income_amount',
formatCurrency(Math.abs(finance.income_amount)), enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.income_amount)),
}, },
{ {
header: 'Aksi', header: 'Aksi',
@@ -605,27 +700,6 @@ const FinanceTable = () => {
}; };
}, [dateErrorShown]); }, [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 ( return (
<> <>
<div className='w-full'> <div className='w-full'>
@@ -687,25 +761,65 @@ const FinanceTable = () => {
}} }}
/> />
<Button <ButtonFilter
variant='outline' values={tableFilterState}
color='none' excludeFields={[
'page',
'pageSize',
'search',
'orderBy',
'bankNames',
'customerNames',
'supplierNames',
]}
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className={cn( className='px-3 py-2.5'
'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, <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>Ekspor</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
> >
<Icon icon='heroicons:funnel' width={20} height={20} /> <Button
Filter variant='ghost'
{hasFilters && ( color='none'
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'> onClick={exportToExcel}
{activeFiltersCount} isLoading={isExportLoading}
</span> className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
)} >
</Button> <Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
</Dropdown>
</div> </div>
</div> </div>
@@ -741,6 +855,9 @@ const FinanceTable = () => {
onPageChange={setPage} onPageChange={setPage}
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{ className={{
containerClassName: cn('p-3 mb-0'), containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap', headerColumnClassName: 'text-nowrap',
@@ -874,19 +991,9 @@ const FinanceTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='button' type='reset'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -7,7 +7,6 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
@@ -25,7 +24,10 @@ import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data'; import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; 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';
import toast from 'react-hot-toast';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant'; import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
@@ -38,27 +40,89 @@ import {
AdjustmentFilterType, AdjustmentFilterType,
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter'; } from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import { CellContext } from '@tanstack/react-table';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<InventoryAdjustment, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `adjustment#${props.row.original.id}`;
const popoverAnchorName = `--anchor-adjustment#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const InventoryAdjustmentTable = () => { const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter<{
search: string;
productCategorySort: string;
productSort: string;
warehouseSort: string;
stockSort: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
transactionTypeFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
productCategorySort: '', productCategorySort: '',
productSort: '', productSort: '',
warehouseSort: '', warehouseSort: '',
stockSort: '', stockSort: '',
productFilter: '', productFilter: undefined,
warehouseFilter: '', warehouseFilter: undefined,
transactionTypeFilter: '', transactionTypeFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -71,6 +135,8 @@ const InventoryAdjustmentTable = () => {
warehouseFilter: 'warehouse_id', warehouseFilter: 'warehouse_id',
transactionTypeFilter: 'transaction_type', transactionTypeFilter: 'transaction_type',
}, },
persist: true,
storeName: 'inventory-adjustment-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -79,22 +145,27 @@ const InventoryAdjustmentTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<AdjustmentFilterType>({ const formik = useFormik<AdjustmentFilterType>({
initialValues: { initialValues: {
product_id: null, product: tableFilterState.productFilter,
warehouse_id: null, warehouse: tableFilterState.warehouseFilter,
transaction_type: null, transaction_type: tableFilterState.transactionTypeFilter,
}, },
validationSchema: AdjustmentFilterSchema, validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || ''); updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse_id || ''); updateFilter('warehouseFilter', values.warehouse || undefined, true);
updateFilter('transactionTypeFilter', values.transaction_type || ''); updateFilter(
'transactionTypeFilter',
values.transaction_type || undefined,
true
);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => { onReset: () => {
updateFilter('productFilter', ''); updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', ''); updateFilter('warehouseFilter', undefined, true);
updateFilter('transactionTypeFilter', ''); updateFilter('transactionTypeFilter', undefined, true);
filterModal.closeModal();
}, },
}); });
@@ -133,85 +204,68 @@ const InventoryAdjustmentTable = () => {
}, []); }, []);
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback( const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
(val: OptionType | OptionType[] | null) => { formik.setFieldValue('product', val);
const product = val as OptionType | null; };
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterWarehouseChange = useCallback( const handleFilterWarehouseChange = (
(val: OptionType | OptionType[] | null) => { val: OptionType | OptionType[] | null
const warehouse = val as OptionType | null; ) => {
const warehouseId = warehouse?.value ? String(warehouse.value) : null; formik.setFieldValue('warehouse', val);
formik.setFieldValue('warehouse_id', warehouseId); };
},
[formik]
);
const handleFilterTransactionTypeChange = useCallback( const handleFilterTransactionTypeChange = (
(val: OptionType | OptionType[] | null) => { val: OptionType | OptionType[] | null
const type = val as OptionType | null; ) => {
const typeValue = type?.value ? String(type.value) : null; formik.setFieldValue('transaction_type', val);
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 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 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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
transaction_type: tableFilterState.transactionTypeFilter ?? undefined,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
const { data: inventoryAdjustments, isLoading } = useSWR( const {
data: inventoryAdjustments,
isLoading,
mutate: refreshAdjustments,
} = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher InventoryAdjustmentApi.getAllFetcher
); );
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await InventoryAdjustmentApi.delete(
selectedAdjustment?.id as number
);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Adjustment!');
refreshAdjustments();
} else {
toast.error(response?.message || 'Failed to delete Adjustment');
}
};
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedAdjustment, setSelectedAdjustment] = useState<
useEffect(() => { InventoryAdjustment | undefined
updateFilter('search', searchValue); >(undefined);
}, [searchValue, updateFilter]); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
useEffect(() => {
setTableState('inventory-adjustment-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo( const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
@@ -314,8 +368,39 @@ const InventoryAdjustmentTable = () => {
header: 'Oleh', header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-', accessorFn: (row) => row.created_user?.name ?? '-',
}, },
{
id: 'actions',
header: 'Aksi',
cell: (props: CellContext<InventoryAdjustment, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedAdjustment(props.row.original);
singleDeleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
], ],
[tableFilterState.pageSize, tableFilterState.page] [
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedAdjustment,
]
); );
const updateSortingFilter = useCallback( const updateSortingFilter = useCallback(
@@ -401,6 +486,8 @@ const InventoryAdjustmentTable = () => {
'productSort', 'productSort',
'warehouseSort', 'warehouseSort',
'stockSort', 'stockSort',
'productName',
'warehouseName',
]} ]}
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className='px-3 py-2.5' className='px-3 py-2.5'
@@ -490,7 +577,7 @@ const InventoryAdjustmentTable = () => {
label='Produk' label='Produk'
placeholder='Pilih Produk' placeholder='Pilih Produk'
options={productOptions} options={productOptions}
value={productIdValue} value={formik.values.product}
onChange={handleFilterProductChange} onChange={handleFilterProductChange}
onInputChange={setProductInputValue} onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions} isLoading={isLoadingProductOptions}
@@ -502,7 +589,7 @@ const InventoryAdjustmentTable = () => {
label='Gudang' label='Gudang'
placeholder='Pilih Gudang' placeholder='Pilih Gudang'
options={warehouseOptions} options={warehouseOptions}
value={warehouseIdValue} value={formik.values.warehouse}
onChange={handleFilterWarehouseChange} onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions} isLoading={isLoadingWarehouseOptions}
@@ -514,7 +601,7 @@ const InventoryAdjustmentTable = () => {
label='Tipe Transaksi' label='Tipe Transaksi'
placeholder='Pilih Tipe Transaksi' placeholder='Pilih Tipe Transaksi'
options={transactionTypeOptions} options={transactionTypeOptions}
value={transactionTypeValue} value={formik.values.transaction_type}
onChange={handleFilterTransactionTypeChange} onChange={handleFilterTransactionTypeChange}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
@@ -524,13 +611,9 @@ const InventoryAdjustmentTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='button' type='reset'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -544,6 +627,21 @@ const InventoryAdjustmentTable = () => {
</div> </div>
</form> </form>
</Modal> </Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Adjustment ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</> </>
); );
}; };
@@ -1,13 +1,23 @@
import { string, object } from 'yup'; import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const AdjustmentFilterSchema = object().shape({ export const AdjustmentFilterSchema = Yup.object().shape({
product_id: string().nullable(), product: Yup.object({
warehouse_id: string().nullable(), value: Yup.string().nullable(),
transaction_type: 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 = { export type AdjustmentFilterType = {
product_id: string | null; product?: OptionType<string>;
warehouse_id: string | null; warehouse?: OptionType<string>;
transaction_type: string | null; transaction_type?: OptionType<string>;
}; };
@@ -15,7 +15,7 @@ import {
InventoryAdjustmentFormSchema, InventoryAdjustmentFormSchema,
InventoryAdjustmentFormValues, InventoryAdjustmentFormValues,
} from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema'; } from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema';
import { KandangApi, LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { import {
ProjectFlockApi, ProjectFlockApi,
ProjectFlockKandangApi, ProjectFlockKandangApi,
@@ -32,8 +32,6 @@ import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Kandang } from '@/types/api/master-data/kandang';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
@@ -119,40 +117,19 @@ const InventoryAdjustmentForm = ({
} }
); );
const { rawData: approvedProjectFlockKandangsRawData } =
useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'id',
'search',
{
step_name: 'Disetujui',
limit: '100',
}
);
const approvedProjectFlockKandangs = useMemo(() => {
if (
approvedProjectFlockKandangsRawData &&
'data' in approvedProjectFlockKandangsRawData
) {
return approvedProjectFlockKandangsRawData.data as ProjectFlockKandang[];
}
return [];
}, [approvedProjectFlockKandangsRawData]);
const { const {
setInputValue: setKandangInputValue, options: projectFlockKandangOptions,
options: kandangOptionsFromApi, loadMore: loadMoreProjectFlockKandangs,
isLoadingOptions: isLoadingKandangOptions, setInputValue: setProjectFlockKandangInputValue,
loadMore: loadMoreKandangs, isLoadingOptions: isLoadingProjectFlockKandangOptions,
} = useSelect<Kandang>( } = useSelect(
selectedProjectFlock ? KandangApi.basePath : '', selectedProjectFlock ? ProjectFlockKandangApi.basePath : '',
'id', 'kandang.id',
'name', 'kandang.name',
'search', 'search',
{ {
location_id: selectedProjectFlockLocationId, step_name: 'Disetujui',
project_flock_id: String(selectedProjectFlock?.value),
} }
); );
@@ -222,26 +199,6 @@ const InventoryAdjustmentForm = ({
return (product?.flags as string[]) || []; return (product?.flags as string[]) || [];
}, [selectedProduct, productOptions]); }, [selectedProduct, productOptions]);
const kandangOptions = useMemo(() => {
let options: OptionType[] = [];
if (selectedProjectFlock) {
const approvedKandangIds = approvedProjectFlockKandangs
.filter((pfk) => pfk.project_flock_id === selectedProjectFlock.value)
.map((pfk) => pfk.kandang_id);
options = kandangOptionsFromApi.filter((kandang) =>
approvedKandangIds.includes(kandang.value as number)
);
}
return options;
}, [
selectedProjectFlock,
kandangOptionsFromApi,
approvedProjectFlockKandangs,
]);
const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>( const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>(
() => ({ () => ({
location: null, location: null,
@@ -693,10 +650,10 @@ const InventoryAdjustmentForm = ({
label='Kandang' label='Kandang'
value={selectedKandang} value={selectedKandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
onInputChange={setKandangInputValue} onInputChange={setProjectFlockKandangInputValue}
options={kandangOptions} options={projectFlockKandangOptions}
onMenuScrollToBottom={loadMoreKandangs} onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isLoading={isLoadingKandangOptions} isLoading={isLoadingProjectFlockKandangOptions}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -1,13 +1,6 @@
'use client'; 'use client';
import { import { ChangeEventHandler, useMemo, useState } from 'react';
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -20,7 +13,8 @@ import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; 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'; import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput'; import SelectInput, { useSelect } from '@/components/input/SelectInput';
@@ -41,9 +35,11 @@ import {
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
props, props,
deleteClickHandler,
}: { }: {
popoverPosition: 'bottom' | 'top'; popoverPosition: 'bottom' | 'top';
props: CellContext<Movement, unknown>; props: CellContext<Movement, unknown>;
deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `movement#${props.row.original.id}`; const popoverId = `movement#${props.row.original.id}`;
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`; const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
@@ -83,6 +79,20 @@ const RowOptionsMenu = ({
Detail Detail
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.inventory.transfer.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div> </div>
</PopoverContent> </PopoverContent>
</div> </div>
@@ -90,20 +100,21 @@ const RowOptionsMenu = ({
}; };
const MovementTable = () => { const MovementTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter<{
search: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
productFilter: '', productFilter: undefined,
warehouseFilter: '', warehouseFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -111,6 +122,8 @@ const MovementTable = () => {
productFilter: 'product_id', productFilter: 'product_id',
warehouseFilter: 'warehouse_id', warehouseFilter: 'warehouse_id',
}, },
persist: true,
storeName: 'movement-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -119,19 +132,20 @@ const MovementTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<MovementFilterType>({ const formik = useFormik<MovementFilterType>({
initialValues: { initialValues: {
product_id: null, product: tableFilterState.productFilter,
warehouse_id: null, warehouse: tableFilterState.warehouseFilter,
}, },
validationSchema: MovementFilterSchema, validationSchema: MovementFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || ''); updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse_id || ''); updateFilter('warehouseFilter', values.warehouse || undefined, true);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => { onReset: () => {
updateFilter('productFilter', ''); updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', ''); updateFilter('warehouseFilter', undefined, true);
filterModal.closeModal();
}, },
}); });
@@ -162,67 +176,59 @@ const MovementTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback( const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
(val: OptionType | OptionType[] | null) => { formik.setFieldValue('product', val);
const product = val as OptionType | null; };
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterWarehouseChange = useCallback( const handleFilterWarehouseChange = (
(val: OptionType | OptionType[] | null) => { val: OptionType | OptionType[] | null
const warehouse = val as OptionType | null; ) => {
const warehouseId = warehouse?.value ? String(warehouse.value) : null; formik.setFieldValue('warehouse', val);
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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
const { data: movements, isLoading } = useSWR( const {
data: movements,
isLoading,
mutate: refreshMovements,
} = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`, `${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher MovementApi.getAllFetcher
); );
useEffect(() => { const singleDeleteHandler = async () => {
updateFilter('search', searchValue); setIsDeleteLoading(true);
}, [searchValue, updateFilter]);
useEffect(() => { const response = await MovementApi.delete(selectedMovement?.id as number);
setTableState('movement-table', pathname);
}, [pathname, setTableState]); singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Movement!');
refreshMovements();
} else {
toast.error(response?.message || 'Failed to delete Movement');
}
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const movementColumns: ColumnDef<Movement>[] = useMemo( const movementColumns: ColumnDef<Movement>[] = useMemo(
@@ -275,16 +281,27 @@ const MovementTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedMovement(props.row.original);
singleDeleteModal.openModal();
};
return ( return (
<RowOptionsMenu <RowOptionsMenu
props={props} props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'} popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/> />
); );
}, },
}, },
], ],
[tableFilterState.pageSize, tableFilterState.page] [
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedMovement,
]
); );
return ( return (
@@ -410,7 +427,7 @@ const MovementTable = () => {
label='Produk' label='Produk'
placeholder='Pilih Produk' placeholder='Pilih Produk'
options={productOptions} options={productOptions}
value={productIdValue} value={formik.values.product}
onChange={handleFilterProductChange} onChange={handleFilterProductChange}
onInputChange={setProductInputValue} onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions} isLoading={isLoadingProductOptions}
@@ -422,7 +439,7 @@ const MovementTable = () => {
label='Gudang' label='Gudang'
placeholder='Pilih Gudang' placeholder='Pilih Gudang'
options={warehouseOptions} options={warehouseOptions}
value={warehouseIdValue} value={formik.values.warehouse}
onChange={handleFilterWarehouseChange} onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions} isLoading={isLoadingWarehouseOptions}
@@ -435,13 +452,9 @@ const MovementTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='button' type='reset'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -455,6 +468,21 @@ const MovementTable = () => {
</div> </div>
</form> </form>
</Modal> </Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</> </>
); );
}; };
@@ -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({ export const MovementFilterSchema = Yup.object().shape({
product_id: string().nullable(), product: Yup.object({
warehouse_id: string().nullable(), value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type MovementFilterType = { export type MovementFilterType = {
product_id: string | null; product?: OptionType<string>;
warehouse_id: string | null; warehouse?: OptionType<string>;
}; };
@@ -56,6 +56,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [productQtyErrorShown, setProductQtyErrorShown] = useState(false); const [productQtyErrorShown, setProductQtyErrorShown] = useState(false);
const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false); const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const productStockCacheRef = useRef<
Map<number, { quantity: number; transfer_available_qty?: number }>
>(new Map());
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const createMovementHandler = useCallback( const createMovementHandler = useCallback(
@@ -82,6 +85,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: number; warehouse_id: number;
warehouse_name: string; warehouse_name: string;
quantity: number; quantity: number;
transfer_available_qty?: number;
} }
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
@@ -336,6 +340,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
prevSourceWarehouseId !== currentSourceWarehouseId && prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null prevSourceWarehouseId !== null
) { ) {
productStockCacheRef.current = new Map();
formik.setFieldValue('products', [ formik.setFieldValue('products', [
{ {
product: null, product: null,
@@ -379,6 +384,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: formik.values.source_warehouse_id warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString() ? formik.values.source_warehouse_id.toString()
: '', : '',
transfer_context: 'inventory_transfer',
stock_mode: 'exclude_chickin',
} }
); );
@@ -391,10 +398,20 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: pw.warehouse.id, warehouse_id: pw.warehouse.id,
warehouse_name: pw.warehouse.name, warehouse_name: pw.warehouse.name,
quantity: pw.quantity, quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
})) }))
: []; : [];
}, [productWarehouses]); }, [productWarehouses]);
useEffect(() => {
productWarehouseOptions.forEach((pw) => {
productStockCacheRef.current.set(pw.product_id, {
quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
});
});
}, [productWarehouseOptions]);
// ===== HELPER FUNCTIONS ===== // ===== HELPER FUNCTIONS =====
const isRepeaterInputError = <T extends 'products' | 'deliveries'>( const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
arrayName: T, arrayName: T,
@@ -836,14 +853,42 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getAvailableStock = useCallback( const getAvailableStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find( const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId (pw) => pw.product_id === productId
); );
return productWarehouse?.quantity ?? 0; if (live) return live.transfer_available_qty ?? live.quantity ?? 0;
const cached = productStockCacheRef.current.get(productId);
return cached?.transfer_available_qty ?? cached?.quantity ?? 0;
}, },
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
const getTotalStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
if (live) return live.quantity ?? 0;
return productStockCacheRef.current.get(productId)?.quantity ?? 0;
},
[productWarehouseOptions, type]
);
const hasAvailableQty = useCallback(
(productId: number) => {
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
if (live) return live.transfer_available_qty !== undefined;
return (
productStockCacheRef.current.get(productId)?.transfer_available_qty !==
undefined
);
},
[productWarehouseOptions]
);
const getProductQtyBottomLabel = useCallback( const getProductQtyBottomLabel = useCallback(
(productIdx: number) => { (productIdx: number) => {
if (type === 'detail') return undefined; if (type === 'detail') return undefined;
@@ -851,16 +896,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return undefined; if (!product || !product.product_id) return undefined;
const availableStock = getAvailableStock(product.product_id); const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0; const requestedQty = Number(product.product_qty) || 0;
const remainingStock = availableStock - requestedQty; const remainingStock = availableStock - requestedQty;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > 0) { if (requestedQty > 0) {
if (isAyamProduct) {
return `Sisa: ${formatNumber(remainingStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Sisa: ${formatNumber(remainingStock)}`; return `Sisa: ${formatNumber(remainingStock)}`;
} }
if (isAyamProduct) {
return `Tersedia: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Tersedia: ${formatNumber(availableStock)}`; return `Tersedia: ${formatNumber(availableStock)}`;
}, },
[formik.values.products, getAvailableStock, type] [
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
); );
const getDeliveryProductQtyBottomLabel = useCallback( const getDeliveryProductQtyBottomLabel = useCallback(
@@ -922,15 +982,26 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return null; if (!product || !product.product_id) return null;
const availableStock = getAvailableStock(product.product_id); const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0; const requestedQty = Number(product.product_qty) || 0;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > availableStock) { if (requestedQty > availableStock) {
if (isAyamProduct) {
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)}, terpakai untuk chickin: ${formatNumber(totalStock - availableStock)})`;
}
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`; return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`;
} }
return null; return null;
}, },
[formik.values.products, getAvailableStock, type] [
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
); );
const validateDeliveryQty = useCallback( const validateDeliveryQty = useCallback(
@@ -4,17 +4,23 @@ import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission'; 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 { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper'; import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory'; import { InventoryProductApi } from '@/services/api/inventory';
import { ProductCategoryApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryProduct } from '@/types/api/inventory/product'; import { InventoryProduct } from '@/types/api/inventory/product';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton'; import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
@@ -71,25 +77,79 @@ const RowOptionsMenu = ({
}; };
const InventoryProductTable = () => { const InventoryProductTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
categoryFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', 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 [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR( const { data: inventoryProducts, isLoading } = useSWR(
@@ -97,17 +157,8 @@ const InventoryProductTable = () => {
InventoryProductApi.getAllFetcher InventoryProductApi.getAllFetcher
); );
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const columns: ColumnDef<InventoryProduct>[] = useMemo( const columns: ColumnDef<InventoryProduct>[] = useMemo(
@@ -182,96 +233,163 @@ const InventoryProductTable = () => {
); );
return ( return (
<div className='w-full'> <>
{/* Header Section */} <div className='w-full'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'> {/* Header Section */}
{/* Action Buttons */} <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-fit flex flex-row gap-3 flex-wrap'> {/* Action Buttons */}
<RequirePermission permissions='lti.inventory.product_stock.create'> <div className='w-fit flex flex-row gap-3 flex-wrap'>
<Button <RequirePermission permissions='lti.inventory.product_stock.create'>
href='/inventory/product/add' <Button
color='primary' href='/inventory/product/add'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm' 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 <Icon icon='heroicons:plus' width={20} height={20} />
</Button> Add Product
</RequirePermission> </Button>
</div> </RequirePermission>
{/* 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> </div>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? ( {/* Search and Filter */}
<div className='p-3'> <div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<InventoryProductTableSkeleton <DebouncedTextInput
columns={columns} name='search'
icon={ placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon <Icon
icon='heroicons:document-text' icon='heroicons:magnifying-glass'
className='text-white'
width={20} width={20}
height={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> </div>
) : ( </div>
<Table<InventoryProduct>
data={ {/* Table Section */}
isResponseSuccess(inventoryProducts) <div className='flex flex-col mb-4'>
? inventoryProducts?.data {isLoading ? (
: [] <div className='w-full flex flex-row justify-center items-center p-4'>
} <span className='loading loading-spinner loading-xl' />
columns={columns} </div>
pageSize={tableFilterState.pageSize} ) : !isResponseSuccess(inventoryProducts) ||
page={ inventoryProducts.data?.length === 0 ? (
isResponseSuccess(inventoryProducts) <div className='p-3'>
? inventoryProducts?.meta?.page <InventoryProductTableSkeleton
: 0 columns={columns}
} icon={
totalItems={ <Icon
isResponseSuccess(inventoryProducts) icon='heroicons:document-text'
? inventoryProducts?.meta?.total_results className='text-white'
: 0 width={20}
} height={20}
onPageChange={setPage} />
onPageSizeChange={setPageSize} }
isLoading={isLoading} />
sorting={sorting} </div>
setSorting={setSorting} ) : (
className={{ <Table<InventoryProduct>
containerClassName: cn('p-3 mb-0'), data={
headerColumnClassName: 'text-nowrap', 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>
</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 Card from '@/components/Card';
import { OptionType } from '@/components/input/SelectInput';
import { FormHeader } from '@/components/helper/form/FormHeader'; 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 StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable'; import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper'; import { formatCurrency, formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product'; import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -11,17 +19,34 @@ const InventoryProductDetail = ({
}: { }: {
inventoryProduct?: InventoryProduct; inventoryProduct?: InventoryProduct;
}) => { }) => {
const stockLogs = useMemo(() => { const filterModal = useModal();
return (
inventoryProduct?.product_warehouses?.flatMap((warehouse) => const { state: filterState, updateFilter } = useTableFilter<{
warehouse.stock_logs.map((log) => ({ warehouse_ids: OptionType<number>[];
...log, }>({
warehouse_name: warehouse.warehouse_name, initial: {
warehouse_id: warehouse.warehouse_id, warehouse_ids: [],
})) },
) || [] persist: true,
); storeName: 'inventory-product-stock-log-filter',
}, [inventoryProduct]); });
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 ( return (
<div className='flex flex-col gap-4 p-4'> <div className='flex flex-col gap-4 p-4'>
@@ -114,7 +139,29 @@ const InventoryProductDetail = ({
productWarehouseStock={inventoryProduct?.product_warehouses ?? []} 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> </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 Card from '@/components/Card';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/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 = ({ 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 ( return (
<Card <div ref={containerRef}>
title='Informasi Stock Produk' <Card
collapsible title={`Informasi Stock Produk - ${productWarehouse.warehouse_name}`}
variant='bordered' collapsible
className={{ variant='bordered'
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',
},
]}
className={{ className={{
containerClassName: 'mt-6', wrapper: 'w-full',
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 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 Card from '@/components/Card';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock } from '@/types/api/inventory/product'; 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 = ({ const StockProductWarehouseTable = ({
productWarehouseStock, productWarehouseStock,
}: { }: {
productWarehouseStock?: ProductWarehouseStock[]; productWarehouseStock?: ProductWarehouseStock[];
}) => { }) => {
const { state: tableFilterState, setPage, setPageSize } = useTableFilter();
return ( return (
<Card <Card
title='Informasi Gudang' title='Informasi Gudang'
@@ -19,32 +48,14 @@ const StockProductWarehouseTable = ({
> >
<Table<ProductWarehouseStock> <Table<ProductWarehouseStock>
data={productWarehouseStock ?? []} data={productWarehouseStock ?? []}
columns={[ columns={stockProductWarehouseTableColumns}
{ pageSize={tableFilterState.pageSize}
header: 'Nama Gudang', page={tableFilterState.page ?? 0}
accessorKey: 'warehouse_name', totalItems={productWarehouseStock?.length ?? 0}
}, onPageChange={setPage}
{ onPageSizeChange={setPageSize}
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);
},
},
]}
className={{ className={{
containerClassName: 'mt-6', containerClassName: 'mt-6 mb-0',
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200', headerRowClassName: 'border-b border-b-gray-200',
@@ -199,6 +199,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD' 'yyyy-MM-DD'
), ),
vehicle_number: product.vehicle_number, vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
}; };
} }
}) })
@@ -368,7 +371,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
const currentProducts = deliveryOrderValues?.find( const currentProducts = deliveryOrderValues?.find(
(product) => product.id == id (product) => product.id == id
); );
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
setSelectedDeliveryProduct(currentProducts ?? values ?? null);
if (id) { if (id) {
setStep(2); setStep(2);
} }
@@ -430,6 +435,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD' 'yyyy-MM-DD'
), ),
vehicle_number: product.vehicle_number, vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
}; };
} }
}) })
@@ -841,7 +849,11 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold' className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected} disabled={deliveryRejected}
> >
Approve {marketing?.data?.latest_approval?.step_number === 1 &&
'Approve'}
{marketing?.data?.latest_approval?.step_number === 2 &&
'Deliver Item'}
</Button> </Button>
</div> </div>
)} )}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { RefObject, useMemo } from 'react'; import { RefObject, useCallback, useMemo } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
@@ -10,22 +10,38 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line'; import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
MarketingFilterFormValues,
MarketingFilterSchema,
} from '@/components/pages/marketing/filter/MarketingFilter';
import { MarketingFilter } from '@/types/api/marketing/marketing'; import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing'; import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing'; 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 { interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: MarketingFilter) => void; onSubmit?: (values: MarketingFilter) => void;
onReset?: () => 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 = ({ const MarketingFilterModal = ({
ref, ref,
onSubmit, onSubmit,
onReset, onReset,
initialValues,
}: MarketingFilterModal) => { }: MarketingFilterModal) => {
const closeModalHandler = () => { const closeModalHandler = () => {
ref.current?.close(); ref.current?.close();
@@ -33,51 +49,35 @@ const MarketingFilterModal = ({
// ===== OPTIONS ===== // ===== OPTIONS =====
const { const {
rawData: productsRawData, options: productsOptions,
isLoadingOptions: isLoadingProductsOptions, isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue, setInputValue: setProductsInputValue,
loadMore: loadMoreProducts, loadMore: loadMoreProducts,
} = useSelect<BaseMarketing>(MarketingApi.basePath, 'id', 'so_number', '', { } = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
limit: 'limit', include_all: 'true',
}); });
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]);
const { const {
options: customersOptions, options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions, isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue, setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers, loadMore: loadMoreCustomers,
} = useSelect(MarketingApi.basePath, 'customer.id', 'customer.name', '', { } = useSelect(CustomerApi.basePath, 'id', 'name', 'search', {
limit: 'limit', has_marketing: 'true',
}); });
const uniqueCustomersOptions = useMemo(() => { const {
const seen = new Set(); options: projectFlockOptions,
return customersOptions.filter((customer) => { rawData: projectFlocksRawData,
if (seen.has(customer.value)) return false; isLoadingOptions: isLoadingProjectFlockOptions,
seen.add(customer.value); setInputValue: setProjectFlockInputValue,
return true; loadMore: loadMoreProjectFlocks,
}); } = useSelect<ProjectFlock>(
}, [customersOptions]); ProjectFlockApi.basePath,
'id',
'flock_name',
'search'
);
const statusOptions = [ const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({ ...MARKETING_APPROVAL_LINE.map((item) => ({
@@ -87,23 +87,30 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' }, { value: 'DITOLAK', label: 'Ditolak' },
]; ];
const formik = useFormik<{ const formik = useFormik<MarketingFilterFormValues>({
product_ids: OptionType[]; initialValues: initialValues || {
status: OptionType | null;
customer_id: OptionType | null;
}>({
initialValues: {
product_ids: [], product_ids: [],
status: null, status: null,
customer_id: null, customer: null,
project_flock: null,
project_flock_kandang: null,
}, },
validationSchema: MarketingFilterSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
const formattedValues = { const formattedValues: MarketingFilter = {
...values,
product_ids: values.product_ids.map((item) => Number(item.value)), 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: values.status?.value.toString() || '',
customer_id: Number(values.customer_id?.value), 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); onSubmit?.(formattedValues);
@@ -116,18 +123,58 @@ 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) => { const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]); formik.setFieldValue('product_ids', val as OptionType[]);
}; };
const customerChangeHandler = (val: OptionType | OptionType[] | null) => { const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', val as OptionType); formik.setFieldValue(
'customer',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
}; };
const statusChangeHandler = (val: OptionType | OptionType[] | null) => { const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val as OptionType); 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 ( return (
<Modal <Modal
ref={ref} ref={ref}
@@ -137,7 +184,7 @@ const MarketingFilterModal = ({
> >
<form <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={formik.handleReset} onReset={formikResetHandler}
className='w-full flex flex-col' className='w-full flex flex-col'
> >
{/* Modal Header */} {/* Modal Header */}
@@ -187,13 +234,44 @@ const MarketingFilterModal = ({
label='Customer' label='Customer'
isClearable isClearable
placeholder='Pilih customer' placeholder='Pilih customer'
options={uniqueCustomersOptions} options={customersOptions}
isLoading={isLoadingCustomersOptions} isLoading={isLoadingCustomersOptions}
value={formik.values.customer_id} value={formik.values.customer}
onChange={customerChangeHandler} onChange={customerChangeHandler}
onInputChange={setCustomersInputValue} onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers} 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> </div>
{/* Modal Footer */} {/* Modal Footer */}
+590 -72
View File
@@ -2,26 +2,39 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput'; 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 Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; 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 { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { import {
MarketingApi, MarketingApi,
SalesOrderApi, SalesOrderApi,
} from '@/services/api/marketing/marketing'; } from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import { import {
BaseSalesOrder, BaseSalesOrder,
Marketing, Marketing,
MarketingFilter, MarketingFilter,
} from '@/types/api/marketing/marketing'; } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; 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 { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react'; import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -154,12 +167,21 @@ const MarketingTable = () => {
); );
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null); const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); 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 router = useRouter();
const deleteModal = useModal(); const deleteModal = useModal();
const confirmationModal = useModal(); const confirmationModal = useModal();
const productsModal = useModal(); const productsModal = useModal();
const deliveryModal = useModal(); const deliveryModal = useModal();
const bulkDeliveryModal = useModal();
const exportProgressInputModal = useModal();
const filterModal = useModal(); const filterModal = useModal();
const { const {
@@ -172,8 +194,17 @@ const MarketingTable = () => {
initial: { initial: {
search: '', search: '',
product_ids: '', product_ids: '',
product_names: '',
status: '', status: '',
status_name: '',
customer_id: '', customer_id: '',
customer_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
sort_by: '',
order_by: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -181,9 +212,43 @@ const MarketingTable = () => {
product_ids: 'product_ids', product_ids: 'product_ids',
status: 'status', status: 'status',
customer_id: 'customer_id', 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 ===== // ===== FETCH DATA =====
const { const {
data: marketing, data: marketing,
@@ -198,26 +263,64 @@ const MarketingTable = () => {
const filterSubmitHandler = (values: MarketingFilter) => { const filterSubmitHandler = (values: MarketingFilter) => {
updateFilter( updateFilter(
'product_ids', '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( updateFilter(
'customer_id', '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] = const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false); useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeliveryLoading, setIsDeliveryLoading] = useState(false);
const filterResetHandler = () => { const filterResetHandler = () => {
updateFilter('product_ids', ''); updateFilter('product_ids', '', true);
updateFilter('status', ''); updateFilter('product_names', '', true);
updateFilter('customer_id', ''); 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 = () => { const approveClickHandler = () => {
setApproveAction('APPROVED'); setApproveAction('APPROVED');
if (selectedApprovalStep === 2) {
bulkDeliveryModal.openModal();
return;
}
confirmationModal.openModal(); confirmationModal.openModal();
}; };
@@ -226,10 +329,13 @@ const MarketingTable = () => {
confirmationModal.openModal(); confirmationModal.openModal();
}; };
const productsClickHandler = (item: Marketing) => { const productsClickHandler = useCallback(
setSelectedItem(item); (item: Marketing) => {
productsModal.openModal(); setSelectedItem(item);
}; productsModal.openModal();
},
[productsModal]
);
const deleteMarketingHandler = async () => { const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete( const deleteMarketingRes = await MarketingApi.delete(
@@ -251,75 +357,226 @@ const MarketingTable = () => {
const selectedRowsData = allData.filter( const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()] (row) => rowSelection[row.id.toString()]
); );
const selectedApprovalStep =
selectedRowsData.length > 0
? selectedRowsData[0].latest_approval.step_number
: null;
const hasApprovable = selectedRowsData.some( const eligibleSelectedRows = selectedRowsData.filter((row) => {
(row) => const approval = row.latest_approval;
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED' if (approval.action === 'REJECTED') {
); return false;
const hasRejectable = selectedRowsData.some( }
(row) =>
row.latest_approval.step_number === 1 && if (selectedApprovalStep === null) {
row.latest_approval.action !== 'REJECTED' 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 disableApprove = !hasApprovable;
const disableReject = !hasRejectable; const disableReject = !hasRejectable;
const idsToProcess = const idsToProcess = eligibleSelectedRows.map((row) => row.id);
approveAction === 'APPROVED' const nextApprovalStatus =
? selectedRowsData selectedApprovalStep === 1
.filter((row) => row.latest_approval.step_number === 1) ? 'SALES_ORDER'
.map((row) => row.id) : selectedApprovalStep === 2
: selectedRowsData ? 'DELIVERY_ORDER'
.filter((row) => row.latest_approval.step_number === 2) : null;
.map((row) => row.id);
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) => { 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) { if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`); toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal(); confirmationModal.closeModal();
return; return;
} }
const approveMarketingRes = await SalesOrderApi.bulkApprovals( if (approveAction === 'APPROVED' && selectedApprovalStep !== 1) {
idsToProcess, toast.error('Approve tahap ini harus menggunakan tanggal pengiriman.');
approveAction, confirmationModal.closeModal();
notes return;
); }
if (isResponseSuccess(approveMarketingRes)) { if (approveAction === 'APPROVED' && !nextApprovalStatus) {
toast.error('Status approval berikutnya tidak valid.');
confirmationModal.closeModal(); 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({}); setRowSelection({});
refreshMarketing();
} finally {
setIsSubmittingBulkDelivery(false);
} }
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
}; };
const confirmationModalDeliveryClickHandler = async (notes: string) => { const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes); setIsDeliveryLoading(true);
deliveryModal.closeModal(); try {
toast.success(res?.message as string); const res = await SalesOrderApi.delivery(
refreshMarketing?.(); selectedItem?.id as number,
router.push( notes
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}` );
); 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 getRowCanSelect = useCallback(
const approval = row.original.latest_approval; (row: Row<Marketing>): boolean => {
return approval?.step_number === 1 && approval?.action !== 'REJECTED'; 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 () => { const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true); setIsLoadingExportingToExcel(true);
@@ -329,6 +586,53 @@ const MarketingTable = () => {
setIsLoadingExportingToExcel(false); 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>[]>(() => { const columns = useMemo<ColumnDef<Marketing>[]>(() => {
return [ return [
{ {
@@ -336,7 +640,22 @@ const MarketingTable = () => {
size: 1, size: 1,
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; 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 = const allSelected =
selectableRows.length > 0 && selectableRows.length > 0 &&
@@ -378,7 +697,7 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'so_do_number', accessorKey: 'so_number',
header: 'No. Order', header: 'No. Order',
cell: (props) => { cell: (props) => {
return props.row.original.do_number return props.row.original.do_number
@@ -394,7 +713,7 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'approval.step_name', accessorKey: 'status',
header: 'Status', header: 'Status',
cell: (props) => { cell: (props) => {
const approval = props.row.original.latest_approval; const approval = props.row.original.latest_approval;
@@ -429,10 +748,12 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'customer.name', accessorKey: 'customer',
header: 'Customer', header: 'Customer',
cell: (props) => props.row.original.customer.name,
}, },
{ {
accessorKey: 'grand_total',
accessorFn: (row) => accessorFn: (row) =>
row.sales_order row.sales_order
?.map((product) => product.total_price) ?.map((product) => product.total_price)
@@ -449,6 +770,7 @@ const MarketingTable = () => {
{ {
accessorKey: 'marketing_products.length', accessorKey: 'marketing_products.length',
header: 'Product Details', header: 'Product Details',
enableSorting: false,
cell: (props) => { cell: (props) => {
if (props?.row?.original?.sales_order?.length) { if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) { 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', id: 'actions',
maxSize: 80, maxSize: 80,
@@ -504,7 +834,13 @@ const MarketingTable = () => {
}, },
}, },
]; ];
}, []); }, [
deleteModal,
deliveryModal,
getRowCanSelect,
productsClickHandler,
selectedApprovalStep,
]);
return ( return (
<> <>
@@ -527,7 +863,7 @@ const MarketingTable = () => {
</RequirePermission> </RequirePermission>
{idsToProcess.length > 0 && ( {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'> <RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button <Button
color='error' color='error'
@@ -541,7 +877,7 @@ const MarketingTable = () => {
width={20} width={20}
height={20} height={20}
/> />
Reject Reject ({idsToProcess.length} Item)
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'> <RequirePermission permissions='lti.marketing.sales_order.approve'>
@@ -557,7 +893,7 @@ const MarketingTable = () => {
width={20} width={20}
height={20} height={20}
/> />
Approve Approve ({idsToProcess.length} Item)
</Button> </Button>
</RequirePermission> </RequirePermission>
</> </>
@@ -566,7 +902,18 @@ const MarketingTable = () => {
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
<ButtonFilter <ButtonFilter
values={tableFilterState} 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={() => { onClick={() => {
filterModal.openModal(); 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' 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} /> <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> </Button>
</Dropdown> </Dropdown>
</div> </div>
@@ -646,6 +1003,9 @@ const MarketingTable = () => {
columns={columns} columns={columns}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1} page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
totalItems={ totalItems={
isResponseSuccess(marketing) isResponseSuccess(marketing)
? marketing?.meta?.total_results ? marketing?.meta?.total_results
@@ -677,14 +1037,16 @@ const MarketingTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={confirmationModal.ref} ref={confirmationModal.ref}
type={approveAction === 'APPROVED' ? 'success' : 'error'} 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={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
isLoading: isApproveLoading,
onClick: confirmationModal.closeModal, onClick: confirmationModal.closeModal,
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: approveAction === 'APPROVED' ? 'success' : 'error', color: approveAction === 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: approveMarketingHandler, onClick: approveMarketingHandler,
}} }}
/> />
@@ -708,14 +1070,169 @@ const MarketingTable = () => {
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`} text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
isLoading: isDeliveryLoading,
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'success', color: 'success',
isLoading: isDeliveryLoading,
onClick: confirmationModalDeliveryClickHandler, 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 <Modal
ref={productsModal.ref} ref={productsModal.ref}
className={{ className={{
@@ -746,7 +1263,7 @@ const MarketingTable = () => {
} }
columns={[ columns={[
{ {
header: 'Kandang', header: 'Gudang Fisik',
accessorFn(row) { accessorFn(row) {
return row.product_warehouse.warehouse.name; return row.product_warehouse.warehouse.name;
}, },
@@ -777,6 +1294,7 @@ const MarketingTable = () => {
ref={filterModal.ref} ref={filterModal.ref}
onSubmit={filterSubmitHandler} onSubmit={filterSubmitHandler}
onReset={filterResetHandler} onReset={filterResetHandler}
initialValues={marketingFilterInitialValues}
/> />
</> </>
); );
@@ -195,7 +195,9 @@ const SalesOrderFormModal = ({
product.marketing_type?.value?.toLowerCase() === 'telur' product.marketing_type?.value?.toLowerCase() === 'telur'
? convertionUnitValue === 'PETI' ? convertionUnitValue === 'PETI'
? 'PETI' ? 'PETI'
: 'KG' // termasuk "QTY" dan "KG" : convertionUnitValue === 'QTY'
? 'QTY'
: 'KG'
: undefined; : undefined;
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM" // Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
@@ -207,7 +209,6 @@ const SalesOrderFormModal = ({
return { return {
vehicle_number: product.vehicle_number as string, vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number, product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(String(product.unit_price || 0)), unit_price: parseFloat(String(product.unit_price || 0)),
total_weight: parseFloat(String(product.total_weight || 0)), total_weight: parseFloat(String(product.total_weight || 0)),
@@ -245,6 +246,7 @@ const SalesOrderFormModal = ({
}) })
.filter((item) => Boolean(item)), .filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload); } as UpdateDeliveryOrderPayload);
switch (modalAction) { switch (modalAction) {
case 'add': case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload); await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -260,11 +262,7 @@ const SalesOrderFormModal = ({
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, setFormErrorList, close, handleFormSubmit } = const { formErrorList, setFormErrorList, close, handleFormSubmit } =
useFormikErrorList(formik, { useFormikErrorList(formik);
onAfterSubmit: () => {
router.push('/marketing');
},
});
// ================== FORM REPEATER HANDLER ================== // ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => { const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
@@ -0,0 +1,18 @@
import { array, mixed, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
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;
};
@@ -13,6 +13,7 @@ import {
Marketing, Marketing,
} from '@/types/api/marketing/marketing'; } from '@/types/api/marketing/marketing';
import { formatDate, formatTitleCase } from '@/lib/helper'; import { formatDate, formatTitleCase } from '@/lib/helper';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type MarketingSchemaType = { type MarketingSchemaType = {
customer_id: number | undefined; customer_id: number | undefined;
@@ -70,14 +71,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
.required('Pengiriman wajib diisi!') .required('Pengiriman wajib diisi!')
.test( .test(
'at-least-one-valid-row', 'at-least-one-valid-row',
'Minimal harus ada satu baris pengiriman yang lengkap diisi!', 'Seluruh data pengiriman harus diisi lengkap!',
function (items) { function (items) {
if (!items || items.length === 0) return false; if (!items || items.length === 0) return false;
// VALIDASI: minimal 1 item valid full // VALIDASI: seluruh item harus valid full
const itemSchema = DeliveryOrderProductSchema; const itemSchema = DeliveryOrderProductSchema;
const hasValidItem = items.some((item) => { const hasValidItem = items.every((item) => {
if (!item) return false; if (!item) return false;
return itemSchema.isValidSync(item, { abortEarly: true }); return itemSchema.isValidSync(item, { abortEarly: true });
}); });
@@ -97,17 +98,21 @@ export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
export const SalesProductToFieldValues = ( export const SalesProductToFieldValues = (
product: BaseSalesOrder product: BaseSalesOrder
): SalesOrderProductFormValues => { ): SalesOrderProductFormValues => {
const warehouseOption = {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
};
return { return {
id: product.id, id: product.id,
vehicle_number: product.vehicle_number, vehicle_number: product.vehicle_number,
warehouse_id: product.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: product.product_warehouse.warehouse.id, kandang_id: product.product_warehouse.warehouse.id,
kandang: { kandang: warehouseOption,
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: { product_warehouse: {
value: product.product_warehouse.id, value: product.product_warehouse.id,
label: product.product_warehouse.product.name, label: getProductWarehouseOptionLabel(product.product_warehouse),
}, },
product_warehouse_data: product.product_warehouse, product_warehouse_data: product.product_warehouse,
product_warehouse_id: product.product_warehouse.id, product_warehouse_id: product.product_warehouse.id,
@@ -118,8 +123,17 @@ export const SalesProductToFieldValues = (
total_price: product.total_price, total_price: product.total_price,
marketing_type: product.marketing_type marketing_type: product.marketing_type
? { ? {
value: product.marketing_type, value:
label: formatTitleCase(product.marketing_type), 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, : null,
convertion_unit: product.convertion_unit convertion_unit: product.convertion_unit
@@ -139,11 +153,36 @@ export const DeliveryProductToFieldValues = (
delivery: BaseDeliveryOrder delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => { ): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => { const data = delivery.deliveries.map((item) => {
const soId = salesOrders.find( const salesOrder =
(so) => so.product_warehouse.id === item.product_warehouse.id salesOrders.find((so) => so.id === item.marketing_product_id) ??
)?.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,
};
const initialSisaBerat =
item?.total_weight &&
salesOrder?.weight_per_convertion &&
salesOrder?.total_peti
? Number(item.total_weight) -
Number(salesOrder.weight_per_convertion) *
Number(salesOrder.total_peti)
: 0;
const initialPricePerConvertion =
item?.total_price &&
salesOrder?.total_peti &&
Number(salesOrder.total_peti) !== 0
? (Number(item.total_price) -
initialSisaBerat * Number(item.unit_price || 0)) /
Number(salesOrder.total_peti)
: Number(item?.unit_price || 0);
return { return {
id: soId, id: salesOrder?.id,
unit_price: item.unit_price, unit_price: item.unit_price,
total_weight: item.total_weight, total_weight: item.total_weight,
qty: item.qty, qty: item.qty,
@@ -152,19 +191,40 @@ export const DeliveryProductToFieldValues = (
vehicle_number: item.vehicle_number, vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'), delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number, do_number: delivery.do_number,
marketing_product_id: soId, marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
marketing_type: 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
? {
value: salesOrder?.convertion_unit.toLowerCase(),
label: formatTitleCase(salesOrder?.convertion_unit),
}
: null,
marketing_product: { marketing_product: {
id: soId, id: item.marketing_product_id ?? salesOrder?.id,
vehicle_number: item.vehicle_number, vehicle_number: item.vehicle_number,
warehouse_id: item.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: item.product_warehouse.warehouse.id, kandang_id: item.product_warehouse.warehouse.id,
kandang: { kandang: warehouseOption,
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
},
product_warehouse: { product_warehouse: {
value: item.product_warehouse.id, value: item.product_warehouse.id,
label: item.product_warehouse.product.name, label: getProductWarehouseOptionLabel(item.product_warehouse),
}, },
product_warehouse_data: item.product_warehouse,
product_warehouse_id: item.product_warehouse.id, product_warehouse_id: item.product_warehouse.id,
unit_price: item.unit_price, unit_price: item.unit_price,
total_weight: item.total_weight, total_weight: item.total_weight,
@@ -172,8 +232,13 @@ export const DeliveryProductToFieldValues = (
avg_weight: item.avg_weight, avg_weight: item.avg_weight,
total_price: item.total_price, total_price: item.total_price,
}, },
total_peti: salesOrder?.total_peti,
weight_per_convertion:
item?.weight_per_convertion ?? salesOrder?.weight_per_convertion ?? 0,
price_per_convertion: initialPricePerConvertion,
} as DeliveryOrderProductFormValues; } as DeliveryOrderProductFormValues;
}); });
return data; return data;
}; };
export const mergeSOwithDO = ( export const mergeSOwithDO = (
@@ -181,10 +246,25 @@ export const mergeSOwithDO = (
deliveryOrders: DeliveryOrderProductFormValues[], deliveryOrders: DeliveryOrderProductFormValues[],
autofill?: boolean autofill?: boolean
): DeliveryOrderProductFormValues[] => { ): DeliveryOrderProductFormValues[] => {
const hasDeliveryOrders = deliveryOrders.length > 0;
return salesOrders.map((so) => { return salesOrders.map((so) => {
const delivery = deliveryOrders.find( const delivery = deliveryOrders.find(
(d) => d?.marketing_product_id === so.id (d) => d?.marketing_product_id === so.id
); );
const isTelurQty =
so.marketing_type?.value?.toLowerCase() === 'telur' &&
so.convertion_unit?.value?.toLowerCase() === 'qty';
const salesOrderUnitPrice =
isTelurQty && Number(so.total_price || 0) > 0 && Number(so.qty || 0) > 0
? Number(so.total_price) / Number(so.qty)
: so.unit_price;
const salesOrderPricePerQty =
isTelurQty &&
Number(so.total_price || 0) > 0 &&
Number(so.total_weight || 0) > 0
? Number(so.total_price) / Number(so.total_weight)
: so.price_per_qty;
return { return {
...so, // nilai dasar dari sales order ...so, // nilai dasar dari sales order
@@ -192,30 +272,50 @@ export const mergeSOwithDO = (
delivery_date: delivery?.delivery_date || undefined, delivery_date: delivery?.delivery_date || undefined,
do_number: delivery?.do_number || undefined, do_number: delivery?.do_number || undefined,
vehicle_number: delivery?.vehicle_number || so.vehicle_number, vehicle_number: delivery?.vehicle_number || so.vehicle_number,
unit_price: autofill ? so.unit_price : delivery?.unit_price, unit_price:
total_weight: autofill ? so.total_weight : delivery?.total_weight, autofill && hasDeliveryOrders
qty: autofill ? so.qty : delivery?.qty, ? delivery?.unit_price
avg_weight: autofill ? so.avg_weight : delivery?.avg_weight, : salesOrderUnitPrice,
total_price: autofill ? so.total_price : delivery?.total_price, total_weight:
autofill && hasDeliveryOrders
? delivery?.total_weight
: so.total_weight,
qty: autofill && hasDeliveryOrders ? delivery?.qty : so.qty,
avg_weight:
autofill && hasDeliveryOrders ? delivery?.avg_weight : so.avg_weight,
total_price:
autofill && hasDeliveryOrders ? delivery?.total_price : so.total_price,
marketing_product: so, // jika ada, override marketing_product: so, // jika ada, override
uom: autofill ? so.uom : delivery?.uom, uom: autofill && hasDeliveryOrders ? delivery?.uom : so.uom,
weight_per_convertion: autofill weight_per_convertion:
? so.weight_per_convertion autofill && hasDeliveryOrders
: delivery?.weight_per_convertion, ? delivery?.weight_per_convertion
price_per_convertion: autofill : so.weight_per_convertion,
? so.price_per_convertion price_per_convertion:
: delivery?.price_per_convertion, autofill && hasDeliveryOrders
convertion_unit: autofill ? delivery?.price_per_convertion
? so.convertion_unit : so.price_per_convertion,
: delivery?.convertion_unit, convertion_unit:
marketing_type: autofill ? so.marketing_type : delivery?.marketing_type, autofill && hasDeliveryOrders
total_peti: autofill ? so.total_peti : delivery?.total_peti, ? delivery?.convertion_unit
price_per_qty: autofill ? so.price_per_qty : delivery?.price_per_qty, : so.convertion_unit,
sisa_berat: autofill ? so.sisa_berat : delivery?.sisa_berat, marketing_type:
price_sisa_berat: autofill autofill && hasDeliveryOrders
? so.price_sisa_berat ? delivery?.marketing_type
: delivery?.price_sisa_berat, : so.marketing_type,
week: autofill ? so.week : delivery?.week, total_peti:
autofill && hasDeliveryOrders ? delivery?.total_peti : so.total_peti,
price_per_qty:
autofill && hasDeliveryOrders
? delivery?.price_per_qty
: salesOrderPricePerQty,
sisa_berat:
autofill && hasDeliveryOrders ? delivery?.sisa_berat : so.sisa_berat,
price_sisa_berat:
autofill && hasDeliveryOrders
? delivery?.price_sisa_berat
: so.price_sisa_berat,
week: autofill && hasDeliveryOrders ? delivery?.week : so.week,
} as DeliveryOrderProductFormValues; } as DeliveryOrderProductFormValues;
}); });
}; };
@@ -32,6 +32,63 @@ import Dropdown from '@/components/Dropdown';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { handleMarketingCalculation } from '@/lib/marketing-calculation'; import { handleMarketingCalculation } from '@/lib/marketing-calculation';
type PricingOption =
| string
| {
value: string;
label: string;
}
| null
| undefined;
type PricingSource =
| {
marketing_type?: PricingOption;
convertion_unit?: PricingOption;
total_price?: string | number | null;
qty?: string | number | null;
total_weight?: string | number | null;
unit_price?: string | number | null;
price_per_qty?: number | null;
}
| null
| undefined;
const getOptionValue = (value?: PricingOption) => {
if (!value) return undefined;
if (typeof value === 'string') return value.toLowerCase();
return value.value?.toLowerCase();
};
const isTelurQtyProduct = (value?: PricingSource) =>
getOptionValue(value?.marketing_type) === 'telur' &&
getOptionValue(value?.convertion_unit) === 'qty';
const getDisplayedUnitPrice = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.qty || 0) > 0
) {
return Number(value?.total_price) / Number(value?.qty);
}
return value?.unit_price ?? undefined;
};
const getDisplayedPricePerQty = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.total_weight || 0) > 0
) {
return Number(value?.total_price) / Number(value?.total_weight);
}
return value?.price_per_qty ?? null;
};
const DeliveryOrderProductForm = ({ const DeliveryOrderProductForm = ({
formState, formState,
salesOrders, salesOrders,
@@ -76,7 +133,7 @@ const DeliveryOrderProductForm = ({
? (Number(initialValues.total_price) - ? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) / initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti) Number(initialValues.total_peti)
: 0; : Number(initialValues?.unit_price || 0);
const initialPriceSisaBerat = const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti initialValues?.total_price && initialValues?.total_peti
@@ -89,15 +146,6 @@ const DeliveryOrderProductForm = ({
); );
// ============ Fetch Data ============ // ============ 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 // Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => { // const optionsWeek = useMemo(() => {
@@ -112,7 +160,7 @@ const DeliveryOrderProductForm = ({
if (!Boolean(item.qty)) { if (!Boolean(item.qty)) {
return { return {
value: item.id, value: item.id,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`, label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.warehouse?.label ?? item.marketing_product?.kandang?.label}`,
} as OptionType; } as OptionType;
} else { } else {
return null; return null;
@@ -133,12 +181,19 @@ const DeliveryOrderProductForm = ({
const deliveryOrder = useMemo(() => { const deliveryOrder = useMemo(() => {
if (!hasDeliveryOrder || !deliveryOrders) return null; if (!hasDeliveryOrder || !deliveryOrders) return null;
const marketingProductId =
initialValues?.marketing_product_id ?? initialValues?.id;
for (const doItem of deliveryOrders) { for (const doItem of deliveryOrders) {
const found = doItem.deliveries.find( const found =
(d) => doItem.deliveries.find(
d.product_warehouse.id === (d) => d.marketing_product_id === marketingProductId
initialValues?.marketing_product?.product_warehouse_id ) ??
); doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
if (found) { if (found) {
return { return {
...found, ...found,
@@ -154,6 +209,27 @@ const DeliveryOrderProductForm = ({
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
const defaultPricingSource: PricingSource = {
marketing_type:
initialValues?.marketing_type ?? salesOrder?.marketing_type ?? null,
convertion_unit:
initialValues?.convertion_unit ?? salesOrder?.convertion_unit ?? null,
total_price:
deliveryOrder?.total_price ??
initialValues?.total_price ??
salesOrder?.total_price,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? salesOrder?.qty,
total_weight:
deliveryOrder?.total_weight ??
initialValues?.total_weight ??
salesOrder?.total_weight,
unit_price:
deliveryOrder?.unit_price ??
initialValues?.unit_price ??
salesOrder?.unit_price,
price_per_qty: initialValues?.price_per_qty ?? null,
};
const formik = useFormik<DeliveryOrderProductFormValues>({ const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
@@ -167,8 +243,7 @@ const DeliveryOrderProductForm = ({
undefined, undefined,
marketing_product_id: marketing_product_id:
salesOrder?.id || initialValues?.marketing_product_id || undefined, salesOrder?.id || initialValues?.marketing_product_id || undefined,
unit_price: unit_price: getDisplayedUnitPrice(defaultPricingSource),
deliveryOrder?.unit_price ?? initialValues?.unit_price ?? undefined,
total_weight: total_weight:
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined, deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined, qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
@@ -186,7 +261,7 @@ const DeliveryOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null, convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null, marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null, total_peti: initialValues?.total_peti ?? null,
price_per_qty: initialValues?.price_per_qty ?? null, price_per_qty: getDisplayedPricePerQty(defaultPricingSource),
sisa_berat: initialSisaBerat, sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat, price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null, week: initialValues?.week ?? null,
@@ -326,14 +401,21 @@ const DeliveryOrderProductForm = ({
useEffect(() => { useEffect(() => {
if (initialValues) { if (initialValues) {
if (!Boolean(initialValues.qty)) { if (
!Boolean(initialValues.qty) &&
!Boolean(initialValues.marketing_product_id)
) {
handleResetForm(); handleResetForm();
} else { } else {
setFormikValues(initialValues); setFormikValues({
...initialValues,
unit_price: getDisplayedUnitPrice(initialValues),
price_per_qty: getDisplayedPricePerQty(initialValues),
});
if (initialValues?.marketing_product_id) { if (initialValues?.marketing_product_id) {
setSelectedProduct({ setSelectedProduct({
value: initialValues?.id, value: initialValues?.marketing_product_id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`, label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
} as OptionType); } as OptionType);
} }
} }
@@ -349,7 +431,8 @@ const DeliveryOrderProductForm = ({
handleBlurField(currentInput); handleBlurField(currentInput);
formik.setFieldValue( formik.setFieldValue(
'uom', 'uom',
isResponseSuccess(productData) ? productData?.data?.uom?.name : '' initialValues?.marketing_product?.product_warehouse_data?.product?.uom
?.name ?? ''
); );
}, },
} }
@@ -458,10 +541,11 @@ const DeliveryOrderProductForm = ({
marketing_product_id: selected.value as number, marketing_product_id: selected.value as number,
marketing_product: soFieldValues, marketing_product: soFieldValues,
qty: so.qty, qty: so.qty,
unit_price: so.unit_price, unit_price: getDisplayedUnitPrice(so),
total_price: so.total_price, total_price: so.total_price,
avg_weight: so.avg_weight, avg_weight: so.avg_weight,
total_weight: so.total_weight, total_weight: so.total_weight,
price_per_qty: getDisplayedPricePerQty(so),
vehicle_number: so.vehicle_number, vehicle_number: so.vehicle_number,
week: soFieldValues.week ?? null, week: soFieldValues.week ?? null,
}); });
@@ -472,7 +556,11 @@ const DeliveryOrderProductForm = ({
text={ text={
exisitingValues?.find( exisitingValues?.find(
(item) => item.id === selectedProduct?.value (item) => item.id === selectedProduct?.value
)?.marketing_product?.kandang?.label ?? '' )?.marketing_product?.warehouse?.label ??
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.kandang?.label ??
''
} }
color='success' color='success'
className={{ className={{
@@ -638,7 +726,7 @@ const DeliveryOrderProductForm = ({
placeholder='Masukan Total Peti' placeholder='Masukan Total Peti'
endAdornment={ endAdornment={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Kg</span> <span className='text-sm text-base-content/50'>Peti</span>
</div> </div>
} }
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`} bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -688,6 +776,9 @@ const DeliveryOrderProductForm = ({
} }
errorMessage={formik.errors.total_weight} errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot' placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/> />
)} )}
@@ -714,9 +805,8 @@ const DeliveryOrderProductForm = ({
endAdornment={ endAdornment={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'> <span className='text-sm text-gray-500'>
{isResponseSuccess(productData) {initialValues?.marketing_product?.product_warehouse_data
? productData?.data?.uom.name ?.product?.uom?.name ?? ''}
: ''}
</span> </span>
</div> </div>
} }
@@ -727,9 +817,8 @@ const DeliveryOrderProductForm = ({
(item) => item.id === formik.values.marketing_product_id (item) => item.id === formik.values.marketing_product_id
)?.qty + )?.qty +
' ' + ' ' +
(isResponseSuccess(productData) (initialValues?.marketing_product?.product_warehouse_data
? productData?.data?.uom.name ?.product?.uom?.name ?? '')
: '')
: '' : ''
} }
/> />
@@ -757,12 +846,32 @@ const DeliveryOrderProductForm = ({
/> />
)} )}
{/* Harga per butir untuk TELUR + QTY */} {/* Harga Satuan */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' && {formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && ( formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput <NumberInput
required required
label='Harga / Butir (Rp)' label='Harga / Kg (Rp)'
name='price_per_qty' name='price_per_qty'
value={formik.values.price_per_qty ?? undefined} value={formik.values.price_per_qty ?? undefined}
onChange={(e) => { onChange={(e) => {
@@ -776,27 +885,7 @@ const DeliveryOrderProductForm = ({
Boolean(formik.errors.price_per_qty) Boolean(formik.errors.price_per_qty)
} }
errorMessage={formik.errors.price_per_qty} errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Butir' placeholder='Masukan Harga per Kg'
/>
)}
{/* Harga Satuan */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${isResponseSuccess(productData) ? productData?.data?.uom?.name : 'Produk'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/> />
)} )}
@@ -3,6 +3,11 @@ import * as Yup from 'yup';
type SalesOrderProductSchemaType = { type SalesOrderProductSchemaType = {
id?: number | undefined; id?: number | undefined;
warehouse_id?: number;
warehouse?: {
value: number;
label: string;
} | null;
kandang_id?: number; kandang_id?: number;
kandang?: { kandang?: {
value: number; value: number;
@@ -44,15 +49,22 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
Yup.object({ Yup.object({
id: Yup.number(), id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'), vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({ warehouse: Yup.object({
value: Yup.number() value: Yup.number()
.min(1, 'Kandang wajib diisi!') .min(1, 'Gudang fisik wajib diisi!')
.required('Kandang wajib diisi!'), .required('Gudang fisik wajib diisi!'),
label: Yup.string().required('Kandang wajib diisi!'), label: Yup.string().required('Gudang fisik wajib diisi!'),
}).nullable(), }).nullable(),
kandang_id: Yup.number() warehouse_id: Yup.number()
.min(1, 'Kandang wajib diisi!') .min(1, 'Gudang fisik wajib diisi!')
.required('Kandang wajib diisi!'), .required('Gudang fisik wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
kandang_id: Yup.number().optional(),
product_warehouse: Yup.object({ product_warehouse: Yup.object({
value: Yup.number() value: Yup.number()
.min(1, 'Produk wajib diisi!') .min(1, 'Produk wajib diisi!')
@@ -7,7 +7,7 @@ import {
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema'; } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useEffect, useMemo, useState } from 'react'; import { RefObject, useEffect, useMemo, useState } from 'react';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi } from '@/services/api/master-data'; import { WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
@@ -31,6 +31,7 @@ import {
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import { handleMarketingCalculation } from '@/lib/marketing-calculation'; import { handleMarketingCalculation } from '@/lib/marketing-calculation';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -67,7 +68,25 @@ const SalesOrderProductForm = ({
? (Number(initialValues.total_price) - ? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) / initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti) Number(initialValues.total_peti)
: 0; : Number(initialValues?.unit_price || 0);
const isInitialTelurQty =
initialValues?.marketing_type?.value?.toLowerCase() === 'telur' &&
initialValues?.convertion_unit?.value?.toLowerCase() === 'qty';
const initialUnitPrice =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.qty || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.qty)
: initialValues?.unit_price || '';
const initialPricePerQty =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.total_weight || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.total_weight)
: (initialValues?.price_per_qty ?? null);
const initialPriceSisaBerat = const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti initialValues?.total_price && initialValues?.total_peti
@@ -84,11 +103,15 @@ const SalesOrderProductForm = ({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
vehicle_number: initialValues?.vehicle_number || '', vehicle_number: initialValues?.vehicle_number || '',
warehouse_id:
initialValues?.warehouse_id ?? initialValues?.kandang_id ?? undefined,
warehouse: initialValues?.warehouse ?? initialValues?.kandang ?? null,
kandang_id: initialValues?.kandang_id || undefined, kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || null, kandang: initialValues?.kandang || null,
product_warehouse: initialValues?.product_warehouse || null, product_warehouse: initialValues?.product_warehouse || null,
product_warehouse_data: initialValues?.product_warehouse_data || null,
product_warehouse_id: initialValues?.product_warehouse_id || undefined, product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialValues?.unit_price || '', unit_price: initialUnitPrice,
total_weight: initialValues?.total_weight || '', total_weight: initialValues?.total_weight || '',
qty: initialValues?.qty || '', qty: initialValues?.qty || '',
avg_weight: initialValues?.avg_weight || '', avg_weight: initialValues?.avg_weight || '',
@@ -102,7 +125,7 @@ const SalesOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null, convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null, marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null, total_peti: initialValues?.total_peti ?? null,
price_per_qty: initialValues?.price_per_qty ?? null, price_per_qty: initialPricePerQty,
sisa_berat: initialSisaBerat, sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat, price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null, week: initialValues?.week ?? null,
@@ -132,11 +155,11 @@ const SalesOrderProductForm = ({
// ===== Options ===== // ===== Options =====
const { const {
options: kandangSourceOptions, options: warehouseOptions,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingWarehouseOptions,
setInputValue: setKandangInputValue, setInputValue: setWarehouseSearchValue,
loadMore: loadMoreKandang, loadMore: loadMoreWarehouses,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
// Options Week dari minggu 1 - 22 // Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => { // const optionsWeek = useMemo(() => {
@@ -147,7 +170,6 @@ const SalesOrderProductForm = ({
// }, []); // }, []);
const { const {
options: warehouseSourceOptions,
rawData: warehouseSourceRawData, rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions, isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue, setInputValue: setWarehouseInputValue,
@@ -156,32 +178,69 @@ const SalesOrderProductForm = ({
ProductWarehouseApi.basePath, ProductWarehouseApi.basePath,
'id', 'id',
'product.name', 'product.name',
'', 'search',
{ {
warehouse_id: formik.values.kandang_id?.toString() ?? '', limit: '100',
available_only: 'true',
warehouse_id: formik.values.warehouse_id?.toString() ?? '',
type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '', type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '',
} }
); );
const productOptionsFiltered = useMemo(() => { const productOptionsFiltered = useMemo(() => {
return warehouseSourceOptions.filter( if (!isResponseSuccess(warehouseSourceRawData)) {
(product) => return initialValues?.product_warehouse
!exisitingValues ? [initialValues.product_warehouse]
?.map((item) => item.product_warehouse_id) : [];
.includes(product.value) }
const selectedProductIds = new Set(
exisitingValues
?.filter((item) => item.id !== initialValues?.id)
.map((item) => Number(item.product_warehouse_id))
.filter((item) => item > 0) ?? []
); );
}, [warehouseSourceOptions, exisitingValues]);
const options = warehouseSourceRawData.data
.filter((item: ProductWarehouse) => !selectedProductIds.has(item.id))
.map((item: ProductWarehouse) => ({
value: item.id,
label: getProductWarehouseOptionLabel(item),
}));
if (
initialValues?.product_warehouse &&
initialValues?.product_warehouse_id
) {
const exists = options.find(
(option) =>
Number(option.value) === Number(initialValues.product_warehouse_id)
);
if (!exists) {
options.push(initialValues.product_warehouse);
}
}
return options;
}, [warehouseSourceRawData, exisitingValues, initialValues]);
// ===== Handler ===== // ===== Handler =====
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang', val as OptionType); const warehouse = (val as OptionType | null) ?? null;
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('warehouse', warehouse);
formik.setFieldValue('warehouse_id', warehouse?.value);
formik.setFieldValue('kandang', warehouse);
formik.setFieldValue('kandang_id', warehouse?.value);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null); formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', ''); formik.setFieldValue('qty', '');
}; };
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const productWarehouseChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('product_warehouse', val as OptionType); formik.setFieldValue('product_warehouse', val as OptionType);
const newId = (val as OptionType)?.value; const newId = (val as OptionType)?.value;
formik.setFieldValue('product_warehouse_id', newId); formik.setFieldValue('product_warehouse_id', newId);
@@ -191,7 +250,13 @@ const SalesOrderProductForm = ({
(item: ProductWarehouse) => item.id === newId (item: ProductWarehouse) => item.id === newId
); );
setSelectedProductWarehouse(productWarehouse || null); setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
if (productWarehouse?.quantity) {
handleFieldChange('qty', productWarehouse?.quantity);
}
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || ''); formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if ( if (
productWarehouse?.week !== undefined && productWarehouse?.week !== undefined &&
@@ -204,6 +269,8 @@ const SalesOrderProductForm = ({
} }
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
setSelectedProductWarehouse(null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', ''); formik.setFieldValue('qty', '');
formik.setFieldValue('uom', ''); formik.setFieldValue('uom', '');
formik.setFieldValue('week', null); formik.setFieldValue('week', null);
@@ -217,9 +284,12 @@ const SalesOrderProductForm = ({
formik.resetForm({ formik.resetForm({
values: { values: {
vehicle_number: '', vehicle_number: '',
warehouse_id: undefined,
warehouse: null,
kandang_id: undefined, kandang_id: undefined,
kandang: null, kandang: null,
product_warehouse: null, product_warehouse: null,
product_warehouse_data: null,
product_warehouse_id: undefined, product_warehouse_id: undefined,
unit_price: '', unit_price: '',
total_weight: '', total_weight: '',
@@ -310,6 +380,10 @@ const SalesOrderProductForm = ({
handleBlurField('week'); handleBlurField('week');
}, [formik.values.week]); }, [formik.values.week]);
useEffect(() => {
setSelectedProductWarehouse(initialValues?.product_warehouse_data || null);
}, [initialValues?.product_warehouse_data]);
return ( return (
<> <>
<form <form
@@ -348,22 +422,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.vehicle_number} errorMessage={formik.errors.vehicle_number}
/> />
{/* Gudang */} {/* Gudang Fisik */}
<SelectInputRadio <SelectInputRadio
required required
label='Gudang' label='Gudang Fisik'
options={kandangSourceOptions} options={warehouseOptions}
isLoading={isLoadingKandangSourceOptions} isLoading={isLoadingWarehouseOptions}
value={formik.values.kandang} value={formik.values.warehouse}
onChange={kandangChangeHandler} onChange={warehouseChangeHandler}
isClearable isClearable
onInputChange={setKandangInputValue} onInputChange={setWarehouseSearchValue}
onMenuScrollToBottom={loadMoreKandang} onMenuScrollToBottom={loadMoreWarehouses}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.warehouse_id && Boolean(formik.errors.warehouse_id)
} }
errorMessage={formik.errors.kandang_id} errorMessage={formik.errors.warehouse_id}
placeholder='Pilih Gudang' placeholder='Pilih Gudang Fisik'
/> />
{/* Kategori */} {/* Kategori */}
@@ -374,8 +448,9 @@ const SalesOrderProductForm = ({
value={formik.values.marketing_type} value={formik.values.marketing_type}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue('marketing_type', val); formik.setFieldValue('marketing_type', val);
warehouseChangeHandler(null); productWarehouseChangeHandler(null);
formik.setFieldValue('product_warehouse', null); formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('convertion_unit', null); formik.setFieldValue('convertion_unit', null);
formik.setFieldValue('weight_per_convertion', null); formik.setFieldValue('weight_per_convertion', null);
@@ -392,18 +467,18 @@ const SalesOrderProductForm = ({
options={productOptionsFiltered} options={productOptionsFiltered}
isLoading={isLoadingWarehouseSourceOptions} isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse} value={formik.values.product_warehouse}
onChange={warehouseChangeHandler} onChange={productWarehouseChangeHandler}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse} onMenuScrollToBottom={loadMoreWarehouse}
isClearable isClearable
placeholder={ placeholder={
formik.values.kandang_id formik.values.warehouse_id
? productOptionsFiltered.length == 0 ? productOptionsFiltered.length == 0
? 'Tidak ada produk yang tersedia' ? 'Tidak ada produk yang tersedia'
: 'Pilih produk' : 'Pilih produk'
: 'Pilih Kandang Terlebih Dahulu' : 'Pilih Gudang Fisik Terlebih Dahulu'
} }
isDisabled={!formik.values.kandang_id} isDisabled={!formik.values.warehouse_id}
isError={ isError={
formik.touched.product_warehouse_id && formik.touched.product_warehouse_id &&
Boolean(formik.errors.product_warehouse_id) Boolean(formik.errors.product_warehouse_id)
@@ -471,7 +546,7 @@ const SalesOrderProductForm = ({
<input <input
type='radio' type='radio'
checked={ checked={
formik.values.convertion_unit?.value === formik.values.convertion_unit?.value.toLowerCase() ===
option.value option.value
} }
onChange={() => null} onChange={() => null}
@@ -494,7 +569,9 @@ const SalesOrderProductForm = ({
} per ${formik.values.convertion_unit?.value}`} } per ${formik.values.convertion_unit?.value}`}
value={formik.values.weight_per_convertion ?? ''} value={formik.values.weight_per_convertion ?? ''}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const value = Number(e.target.value)
? Number(e.target.value)
: '';
handleFieldChange('weight_per_convertion', value, () => handleFieldChange('weight_per_convertion', value, () =>
setCurrentInput(e.target.name) setCurrentInput(e.target.name)
); );
@@ -548,7 +625,7 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Peti' placeholder='Masukan Total Peti'
endAdornment={ endAdornment={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Kg</span> <span className='text-sm text-base-content/50'>Peti</span>
</div> </div>
} }
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`} bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -598,6 +675,9 @@ const SalesOrderProductForm = ({
} }
errorMessage={formik.errors.total_weight} errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot' placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/> />
)} )}
@@ -665,12 +745,34 @@ const SalesOrderProductForm = ({
/> />
)} )}
{/* Harga per butir untuk TELUR + QTY */} {/* Harga Satuan per Uom Produk Warehouse */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan...'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' && {formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && ( formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput <NumberInput
required required
label='Harga / Butir (Rp)' label='Harga / Kg (Rp)'
name='price_per_qty' name='price_per_qty'
value={formik.values.price_per_qty ?? undefined} value={formik.values.price_per_qty ?? undefined}
onChange={(e) => { onChange={(e) => {
@@ -684,29 +786,7 @@ const SalesOrderProductForm = ({
Boolean(formik.errors.price_per_qty) Boolean(formik.errors.price_per_qty)
} }
errorMessage={formik.errors.price_per_qty} errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Butir' placeholder='Masukan Harga per Kg'
/>
)}
{/* Harga Satuan per Uom Produk Warehouse */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label !== 'qty' ? 'Kg' : (selectedProductWarehouse?.product?.uom?.name ?? 'Produk')} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/> />
)} )}
@@ -5,8 +5,9 @@ import { Icon } from '@iconify/react';
import { useRef, useMemo } from 'react'; import { useRef, useMemo } from 'react';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport'; import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import { Marketing, BaseDelivery } from '@/types/api/marketing/marketing'; import { Marketing } from '@/types/api/marketing/marketing';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { DeliveryProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
type DeliveryOrderProductTableProps = { type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[]; data: DeliveryOrderProductFormValues[];
@@ -55,14 +56,17 @@ const DeliveryOrderProductTable = ({
const deliveryItems = useMemo(() => { const deliveryItems = useMemo(() => {
if (!hasDeliveryOrder) return []; if (!hasDeliveryOrder) return [];
return ( return (
marketing?.delivery_order?.flatMap((doItem) => marketing?.delivery_order?.flatMap((doItem) =>
doItem.deliveries.map((delivery) => ({ DeliveryProductToFieldValues(marketing?.sales_order, doItem).map(
...delivery, (delivery) => ({
do_number: doItem.do_number, ...delivery,
delivery_date: doItem.delivery_date, do_number: doItem.do_number,
warehouse: doItem.warehouse, delivery_date: doItem.delivery_date,
})) warehouse: doItem.warehouse,
})
)
) ?? [] ) ?? []
); );
}, [marketing?.delivery_order, hasDeliveryOrder]); }, [marketing?.delivery_order, hasDeliveryOrder]);
@@ -81,7 +85,7 @@ const DeliveryOrderProductTable = ({
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'> <th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'> <div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div> <div>Value</div>
{formType !== 'success' && {/* {formType !== 'success' &&
(formType === 'add_delivery' || (formType === 'add_delivery' ||
formType === 'edit_delivery' || formType === 'edit_delivery' ||
formType === 'detail') && ( formType === 'detail') && (
@@ -98,15 +102,16 @@ const DeliveryOrderProductTable = ({
<Icon icon='heroicons:pencil' width={20} height={20} /> <Icon icon='heroicons:pencil' width={20} height={20} />
</Button> </Button>
</div> </div>
)} )} */}
</div> </div>
</th> </th>
</tr> </tr>
<> <>
<tr> <tr>
<td className='text-sm px-4 py-3'>Gudang</td> <td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{doItem?.warehouse?.name || {doItem?.warehouse?.name ||
item.marketing_product?.warehouse?.label ||
item.marketing_product?.product_warehouse_data?.warehouse?.name} item.marketing_product?.product_warehouse_data?.warehouse?.name}
</td> </td>
</tr> </tr>
@@ -119,7 +124,7 @@ const DeliveryOrderProductTable = ({
<tr> <tr>
<td className='text-sm px-4 py-3'>Qty</td> <td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'> <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 ?? ''}` ? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
: '-'} : '-'}
</td> </td>
@@ -136,12 +141,15 @@ const DeliveryOrderProductTable = ({
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Bobot</td> <td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatNumber(Number(item.total_weight))} {formatNumber(Number(item.total_weight))} Kg
</td> </td>
</tr> </tr>
)} )}
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td> <td className='text-sm px-4 py-3'>
Total Harga Satuan
{item.convertion_unit?.label.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))} {formatCurrency(parseFloat(item.unit_price as string))}
</td> </td>
@@ -211,7 +219,7 @@ const DeliveryOrderProductTable = ({
}; };
const renderDeliveryOrderContent = ( const renderDeliveryOrderContent = (
item: BaseDelivery & { item: DeliveryOrderProductFormValues & {
do_number: string; do_number: string;
delivery_date: string; delivery_date: string;
warehouse: Warehouse; warehouse: Warehouse;
@@ -230,25 +238,43 @@ const DeliveryOrderProductTable = ({
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'> <th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'> <div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div> <div>Value</div>
{formType !== 'success' &&
(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number, item);
}}
className='p-0 hover:text-base-content'
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
</div>
)}
</div> </div>
</th> </th>
</tr> </tr>
<> <>
<tr> <tr>
<td className='text-sm px-4 py-3'>Gudang</td> <td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>{item.warehouse?.name}</td> <td className='text-sm px-4 py-3'>{item.warehouse?.name}</td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Produk</td> <td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{item.product_warehouse?.product?.name} {item.marketing_product?.product_warehouse_data?.product.name}
</td> </td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Qty</td> <td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{item.qty {item.qty !== undefined && item.qty !== null && item.qty !== ''
? `${formatNumber(item.qty)} ${item.product_warehouse?.product?.uom?.name ?? ''}` ? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -271,13 +297,13 @@ const DeliveryOrderProductTable = ({
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td> <td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(item.unit_price)} {formatCurrency(Number(item.unit_price))}
</td> </td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td> <td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(item.total_price)} {formatCurrency(Number(item.total_price))}
</td> </td>
</tr> </tr>
</> </>
@@ -333,7 +359,9 @@ const DeliveryOrderProductTable = ({
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'> <div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
{hasDeliveryOrder {hasDeliveryOrder
? deliveryItems.map((item, index) => ( ? deliveryItems.map((item, index) => (
<div key={`do-table-${item.product_warehouse?.id}-${index}`}> <div
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
>
{formType === 'success' ? ( {formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'> <div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table <table
@@ -349,8 +377,11 @@ const DeliveryOrderProductTable = ({
</div> </div>
) : ( ) : (
<Card <Card
key={`do-table-${item.product_warehouse?.id}-${index}`} key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
title={item.product_warehouse?.product?.name || 'Produk'} title={
item.marketing_product?.product_warehouse_data?.product
.name || 'Produk'
}
collapsible={true} collapsible={true}
defaultCollapsed={false} defaultCollapsed={false}
variant='bordered' variant='bordered'
@@ -73,8 +73,10 @@ const SalesOrderProductTable = ({
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td> <td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Gudang</td> <td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>{item.kandang?.label}</td> <td className='text-sm px-4 py-3'>
{item.warehouse?.label ?? item.kandang?.label}
</td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Kategori</td> <td className='text-sm px-4 py-3'>Kategori</td>
@@ -135,8 +137,22 @@ const SalesOrderProductTable = ({
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`} {`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
</td> </td>
</tr> </tr>
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
<tr>
<td className='text-sm px-4 py-3'>Harga Satuan Per Peti</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(
parseFloat(item.unit_price as string) *
parseFloat(String(item.weight_per_convertion))
)}
</td>
</tr>
)}
<tr> <tr>
<td className='text-sm px-4 py-3'>Harga Satuan</td> <td className='text-sm px-4 py-3'>
Harga Satuan
{item.convertion_unit?.value.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))} {formatCurrency(parseFloat(item.unit_price as string))}
</td> </td>
@@ -101,8 +101,8 @@ const PDFDocument = ({
<View style={pdfStyles.header}> <View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text> <Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}> <Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Cipedes, Kec. Sukajadi, Kota Bandung 40162 Bandung Barat, Jawa Barat 40514
</Text> </Text>
<View style={pdfStyles.divider} /> <View style={pdfStyles.divider} />
</View> </View>
@@ -87,8 +87,8 @@ const PDFDocument = ({ data }: { data: Marketing }) => {
<View style={pdfStyles.header}> <View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text> <Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}> <Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Cipedes, Kec. Sukajadi, Kota Bandung 40162 Bandung Barat, Jawa Barat 40514
</Text> </Text>
<View style={pdfStyles.divider} /> <View style={pdfStyles.divider} />
</View> </View>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
}; };
const AreasTable = () => { const AreasTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -114,12 +109,14 @@ const AreasTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: searchValue, search: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'areas-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -137,17 +134,8 @@ const AreasTable = () => {
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined); const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('areas-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { BankApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
}; };
const BanksTable = () => { const BanksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -114,12 +109,14 @@ const BanksTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: searchValue, search: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'banks-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -137,17 +134,8 @@ const BanksTable = () => {
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined); const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('banks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { CustomerApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
}; };
const CustomersTable = () => { const CustomersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -114,12 +109,14 @@ const CustomersTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: searchValue, search: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'customers-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -139,17 +136,8 @@ const CustomersTable = () => {
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('customers-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -201,6 +189,11 @@ const CustomersTable = () => {
accessorKey: 'email', accessorKey: 'email',
header: 'Email', header: 'Email',
}, },
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props: CellContext<Customer, unknown>) => { cell: (props: CellContext<Customer, unknown>) => {
@@ -27,6 +27,9 @@ export const CustomerFormSchema = Yup.object({
.email('Format email tidak valid!') .email('Format email tidak valid!')
.required('Email wajib diisi!'), .required('Email wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string() account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'), .required('Nomor rekening wajib diisi!'),
@@ -142,6 +142,7 @@ const CustomerForm = ({
}, },
type: normalizeType(initialValues?.type), type: normalizeType(initialValues?.type),
address: initialValues?.address ?? '', address: initialValues?.address ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '', account_number: initialValues?.account_number ?? '',
}; };
}, [initialValues]); }, [initialValues]);
@@ -164,6 +165,7 @@ const CustomerForm = ({
pic_id: values.picId, pic_id: values.picId,
type: (values.type as OptionType).value as string, type: (values.type as OptionType).value as string,
address: values.address, address: values.address,
bank_name: values.bank_name,
account_number: values.account_number, account_number: values.account_number,
}; };
@@ -286,6 +288,22 @@ const CustomerForm = ({
errorMessage={formik.errors.phone} errorMessage={formik.errors.phone}
readOnly={formType === 'detail'} 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 <TextInput
required required
label='Nomor Rekening' label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { FlockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
}; };
const FlockTable = () => { const FlockTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -114,12 +109,14 @@ const FlockTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: searchValue, search: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'flock-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -139,17 +136,8 @@ const FlockTable = () => {
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('flocks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,13 +1,6 @@
'use client'; 'use client';
import { import { ChangeEventHandler, useMemo, useState } from 'react';
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -35,7 +28,6 @@ import { User } from '@/types/api/api-general';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
KandangFilterSchema, KandangFilterSchema,
KandangFilterType, KandangFilterType,
@@ -122,20 +114,21 @@ const RowOptionsMenu = ({
}; };
const KandangsTable = () => { const KandangsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
locationFilter: '', locationFilter: undefined,
picFilter: '', picFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -143,6 +136,8 @@ const KandangsTable = () => {
locationFilter: 'location_id', locationFilter: 'location_id',
picFilter: 'pic_id', picFilter: 'pic_id',
}, },
persist: true,
storeName: 'kandangs-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -151,22 +146,34 @@ const KandangsTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<KandangFilterType>({ const formik = useFormik<KandangFilterType>({
initialValues: { initialValues: {
location_id: null, location: tableFilterState.locationFilter,
pic_id: null, pic: tableFilterState.picFilter,
}, },
validationSchema: KandangFilterSchema, validationSchema: KandangFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('locationFilter', values.location_id || ''); updateFilter('locationFilter', values.location || undefined, true);
updateFilter('picFilter', values.pic_id || ''); updateFilter('picFilter', values.pic || undefined, true);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); 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 ===== // ===== LOCATION OPTIONS =====
const { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
@@ -194,43 +201,15 @@ const KandangsTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterLocationChange = useCallback( const handleFilterLocationChange = (
(val: OptionType | OptionType[] | null) => { val: OptionType | OptionType[] | null
const location = val as OptionType | null; ) => {
const locationId = location?.value ? String(location.value) : null; setFieldValue('location', val);
};
formik.setFieldValue('location_id', locationId); const handleFilterPicChange = (val: OptionType | OptionType[] | null) => {
}, setFieldValue('pic', val);
[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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -255,17 +234,8 @@ const KandangsTable = () => {
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('kandangs-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -314,6 +284,10 @@ const KandangsTable = () => {
accessorFn: (row) => row.pic?.name ?? '-', accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC', header: 'PIC',
}, },
{
accessorFn: (row) => row.kandang_group?.name ?? '-',
header: 'Kandang Group',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props: CellContext<Kandang, unknown>) => { cell: (props: CellContext<Kandang, unknown>) => {
@@ -471,13 +445,13 @@ const KandangsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
placeholder='Pilih Lokasi' placeholder='Pilih Lokasi'
options={locationOptions} options={locationOptions}
value={locationIdValue} value={formik.values.location}
onChange={handleFilterLocationChange} onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
@@ -490,7 +464,7 @@ const KandangsTable = () => {
label='PIC' label='PIC'
placeholder='Pilih PIC' placeholder='Pilih PIC'
options={picOptions} options={picOptions}
value={picIdValue} value={formik.values.pic}
onChange={handleFilterPicChange} onChange={handleFilterPicChange}
onInputChange={setPicInputValue} onInputChange={setPicInputValue}
isLoading={isLoadingPicOptions} isLoading={isLoadingPicOptions}
@@ -506,17 +480,14 @@ const KandangsTable = () => {
type='button' type='button'
variant='soft' 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' 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={() => { onClick={formikResetHandler}
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </Button>
<Button <Button
type='submit' type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting} disabled={!formik.isValid}
> >
Apply Filter Apply Filter
</Button> </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({ export const KandangFilterSchema = Yup.object().shape({
location_id: string().nullable(), location: Yup.object({
pic_id: string().nullable(), value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
pic: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type KandangFilterType = { export type KandangFilterType = {
location_id: string | null; location?: OptionType<string>;
pic_id: string | null; pic?: OptionType<string>;
}; };
@@ -1,3 +1,4 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup'; import * as Yup from 'yup';
type KandangFormSchemaType = { type KandangFormSchemaType = {
@@ -19,6 +20,7 @@ type KandangFormSchemaType = {
} }
| undefined | undefined
| null; | null;
group?: OptionType;
}; };
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> = export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
@@ -42,6 +44,11 @@ export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
group: Yup.object({
value: Yup.number().min(1).required('Kandang Grup wajib diisi!'),
label: Yup.string().required('Kandang Grup wajib diisi!'),
}).required('Kandang Grup wajib diisi!'),
}); });
export const UpdateKandangFormSchema = KandangFormSchema; export const UpdateKandangFormSchema = KandangFormSchema;
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useFormik } from 'formik'; import { getIn, useFormik } from 'formik';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -34,6 +34,8 @@ import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general'; import { User } from '@/types/api/api-general';
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
interface KandangFormProps { interface KandangFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -96,6 +98,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
label: initialValues.pic.name, label: initialValues.pic.name,
} }
: null, : null,
group: initialValues?.kandang_group
? {
value: initialValues.kandang_group.id,
label: initialValues.kandang_group.name,
}
: undefined,
}; };
}, [initialValues]); }, [initialValues]);
@@ -111,6 +119,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
location_id: values.locationId!, location_id: values.locationId!,
capacity: values.capacity ? parseInt(values.capacity.toString()) : 0, capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
pic_id: values.picId!, pic_id: values.picId!,
group_id: values.group?.value as number,
}; };
switch (type) { switch (type) {
@@ -162,6 +171,23 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
formik.setFieldValue('picId', (val as OptionType)?.value); formik.setFieldValue('picId', (val as OptionType)?.value);
}; };
// Kandang Group
const {
setInputValue: setKandangGroupSelectInputValue,
options: kandangGroupOptions,
isLoadingOptions: isLoadingKandangGroupOptions,
loadMore: loadMoreKandangGroups,
} = useSelect<DailyChecklistKandang>(
DailyChecklistKandangApi.basePath,
'id',
'name'
);
const kandangGroupChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('group', true);
formik.setFieldValue('group', val);
};
const deleteKandangClickHandler = () => { const deleteKandangClickHandler = () => {
deleteModal.openModal(); deleteModal.openModal();
}; };
@@ -269,6 +295,24 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
<SelectInput
required
label='Kandang Group'
value={formik.values.group ?? undefined}
onChange={kandangGroupChangeHandler}
options={kandangGroupOptions}
onInputChange={setKandangGroupSelectInputValue}
onMenuScrollToBottom={loadMoreKandangGroups}
isLoading={isLoadingKandangGroupOptions}
isError={formik.touched.group && Boolean(formik.errors.group)}
errorMessage={
getIn(formik.errors.group, 'value') ??
(formik.errors.group as string)
}
isDisabled={type === 'detail'}
isClearable
/>
</div> </div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
@@ -1,13 +1,6 @@
'use client'; 'use client';
import { import { ChangeEventHandler, useMemo, useState } from 'react';
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { LocationApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
LocationFilterSchema, LocationFilterSchema,
LocationFilterType, LocationFilterType,
@@ -118,25 +110,27 @@ const RowOptionsMenu = ({
}; };
const LocationsTable = () => { const LocationsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter<{
search: string;
areaFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
areaFilter: '', areaFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
areaFilter: 'area_id', areaFilter: 'area_id',
}, },
persist: true,
storeName: 'locations-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -145,19 +139,28 @@ const LocationsTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<LocationFilterType>({ const formik = useFormik<LocationFilterType>({
initialValues: { initialValues: {
area_id: null, area: tableFilterState.areaFilter,
}, },
validationSchema: LocationFilterSchema, validationSchema: LocationFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || ''); updateFilter('areaFilter', values.area || undefined, true);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('areaFilter', '');
},
}); });
const formikResetHandler = () => {
updateFilter('areaFilter', undefined, true);
formik.resetForm({
values: {
area: undefined,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS ===== // ===== AREA OPTIONS =====
const { const {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
@@ -172,24 +175,9 @@ const LocationsTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterAreaChange = useCallback( const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
(val: OptionType | OptionType[] | null) => { formik.setFieldValue('area', val);
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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -212,19 +200,10 @@ const LocationsTable = () => {
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('locations-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -425,13 +404,13 @@ const LocationsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Area' label='Area'
placeholder='Pilih Area' placeholder='Pilih Area'
options={areaOptions} options={areaOptions}
value={areaIdValue} value={formik.values.area}
onChange={handleFilterAreaChange} onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions} isLoading={isLoadingAreaOptions}
@@ -447,10 +426,7 @@ const LocationsTable = () => {
type='button' type='button'
variant='soft' 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' 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={() => { onClick={formikResetHandler}
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </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({ export const LocationFilterSchema = Yup.object().shape({
area_id: string().nullable(), area: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type LocationFilterType = { 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 { NonstockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
}; };
const NonstocksTable = () => { const NonstocksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -114,22 +109,16 @@ const NonstocksTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: searchValue, search: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', 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 [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -148,8 +137,7 @@ const NonstocksTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { ProductCategoryApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
}; };
const ProductCategoryTable = () => { const ProductCategoryTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -120,12 +115,10 @@ const ProductCategoryTable = () => {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'product-category-table',
}); });
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -144,8 +137,7 @@ const ProductCategoryTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -214,10 +206,6 @@ const ProductCategoryTable = () => {
[tableFilterState.pageSize, tableFilterState.page, deleteModal] [tableFilterState.pageSize, tableFilterState.page, deleteModal]
); );
useEffect(() => {
setTableState('product-category-table', pathname);
}, [pathname, setTableState]);
return ( return (
<> <>
<div className='w-full'> <div className='w-full'>
@@ -1,13 +1,6 @@
'use client'; 'use client';
import { import { ChangeEventHandler, useMemo, useState } from 'react';
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -33,7 +26,6 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
import { formatCurrency } from '@/lib/helper'; import { formatCurrency } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
ProductFilterSchema, ProductFilterSchema,
ProductFilterType, ProductFilterType,
@@ -119,25 +111,27 @@ const RowOptionsMenu = ({
}; };
const ProductsTable = () => { const ProductsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter<{
search: string;
productCategoryFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
productCategoryFilter: '', productCategoryFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
productCategoryFilter: 'product_category_id', productCategoryFilter: 'product_category_id',
}, },
persist: true,
storeName: 'product-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -146,19 +140,32 @@ const ProductsTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<ProductFilterType>({ const formik = useFormik<ProductFilterType>({
initialValues: { initialValues: {
product_category_id: null, product_category: tableFilterState.productCategoryFilter,
}, },
validationSchema: ProductFilterSchema, validationSchema: ProductFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('productCategoryFilter', values.product_category_id || ''); updateFilter(
'productCategoryFilter',
values.product_category || undefined,
true
);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('productCategoryFilter', '');
},
}); });
const formikResetHandler = () => {
updateFilter('productCategoryFilter', undefined, true);
formik.resetForm({
values: {
product_category: undefined,
},
});
filterModal.closeModal();
};
// ===== PRODUCT CATEGORY OPTIONS ===== // ===== PRODUCT CATEGORY OPTIONS =====
const { const {
setInputValue: setProductCategoryInputValue, setInputValue: setProductCategoryInputValue,
@@ -173,25 +180,11 @@ const ProductsTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterProductCategoryChange = useCallback( const handleFilterProductCategoryChange = (
(val: OptionType | OptionType[] | null) => { val: OptionType | OptionType[] | null
const category = val as OptionType | null; ) => {
const categoryId = category?.value ? String(category.value) : null; formik.setFieldValue('product_category', val);
};
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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -199,10 +192,6 @@ const ProductsTable = () => {
formik.validateForm(); formik.validateForm();
}; };
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -220,13 +209,8 @@ const ProductsTable = () => {
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setTableState('product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -477,13 +461,13 @@ const ProductsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Kategori Produk' label='Kategori Produk'
placeholder='Pilih Kategori Produk' placeholder='Pilih Kategori Produk'
options={productCategoryOptions} options={productCategoryOptions}
value={productCategoryIdValue} value={formik.values.product_category}
onChange={handleFilterProductCategoryChange} onChange={handleFilterProductCategoryChange}
onInputChange={setProductCategoryInputValue} onInputChange={setProductCategoryInputValue}
isLoading={isLoadingProductCategoryOptions} isLoading={isLoadingProductCategoryOptions}
@@ -499,10 +483,7 @@ const ProductsTable = () => {
type='button' type='button'
variant='soft' 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' 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={() => { onClick={formikResetHandler}
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </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({ export const ProductFilterSchema = Yup.object().shape({
product_category_id: string().nullable(), product_category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type ProductFilterType = { export type ProductFilterType = {
product_category_id: string | null; product_category?: OptionType<string>;
}; };
@@ -154,17 +154,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku, sku: values.sku,
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0, product_price: parseFloat(values.product_price.toString()) || 0,
selling_price: values.selling_price selling_price: values.selling_price
? parseInt(values.selling_price.toString()) || 0 ? parseFloat(values.selling_price.toString()) || 0
: undefined, : undefined,
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined, tax: values.tax ? parseFloat(values.tax.toString()) || 0 : undefined,
expiry_period: values.expiry_period expiry_period: values.expiry_period
? parseInt(values.expiry_period.toString()) || 0 ? parseFloat(values.expiry_period.toString()) || 0
: undefined, : undefined,
suppliers: values.suppliers.map((s) => ({ suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number, supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0, price: parseFloat(s.price.toString()) || 0,
})), })),
flag: values.flag, flag: values.flag,
sub_flags: values.sub_flags, sub_flags: values.sub_flags,
@@ -128,27 +128,44 @@ const ProductionStandardTable = () => {
pageSize: 'limit', pageSize: 'limit',
projectCategoryFilter: 'project_category', projectCategoryFilter: 'project_category',
}, },
persist: true,
storeName: 'production-standard-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
const filterModal = useModal(); const filterModal = useModal();
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
const filterInitialValues = useMemo<ProductionStandardFilterType>(
() => ({
project_category: tableFilterState.projectCategoryFilter || null,
}),
[tableFilterState.projectCategoryFilter]
);
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<ProductionStandardFilterType>({ const formik = useFormik<ProductionStandardFilterType>({
initialValues: { initialValues: filterInitialValues,
project_category: null,
},
validationSchema: ProductionStandardFilterSchema, validationSchema: ProductionStandardFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('projectCategoryFilter', values.project_category || ''); updateFilter('projectCategoryFilter', values.project_category || '');
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('projectCategoryFilter', '');
},
}); });
const formikResetHandler = () => {
updateFilter('projectCategoryFilter', '', true);
formik.resetForm({
values: {
project_category: null,
},
});
filterModal.closeModal();
};
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) ===== // ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
const projectCategoryOptions = useMemo( const projectCategoryOptions = useMemo(
() => [ () => [
@@ -381,7 +398,7 @@ const ProductionStandardTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio <SelectInputRadio
label='Kategori' label='Kategori'
@@ -397,13 +414,9 @@ const ProductionStandardTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='button' type='reset'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -7,7 +7,6 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { SupplierApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
SupplierFilterSchema, SupplierFilterSchema,
SupplierFilterType, SupplierFilterType,
@@ -117,20 +116,21 @@ const RowOptionsMenu = ({
}; };
const SuppliersTable = () => { const SuppliersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
flagFilter?: string;
}>({
initial: { initial: {
search: '', search: '',
categoryFilter: '', categoryFilter: undefined,
flagFilter: '', flagFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -138,6 +138,8 @@ const SuppliersTable = () => {
categoryFilter: 'category_id', categoryFilter: 'category_id',
flagFilter: 'flag', flagFilter: 'flag',
}, },
persist: true,
storeName: 'supplier-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -146,26 +148,33 @@ const SuppliersTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<SupplierFilterType>({ const formik = useFormik<SupplierFilterType>({
initialValues: { initialValues: {
category_id: null, category: tableFilterState.categoryFilter,
flag: false, flag: tableFilterState.flagFilter === 'EKSPEDISI',
}, },
validationSchema: SupplierFilterSchema, validationSchema: SupplierFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category_id || ''); updateFilter('categoryFilter', values.category || undefined, true);
updateFilter( updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true);
'flagFilter',
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); 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; const { setFieldValue } = formik;
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) ===== // ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
@@ -187,15 +196,11 @@ const SuppliersTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterCategoryChange = useCallback( const handleFilterCategoryChange = (
(val: OptionType | OptionType[] | null) => { val: OptionType | OptionType[] | null
const option = val as OptionType | null; ) => {
const categoryId = option?.value ? String(option.value) : null; setFieldValue('category', val);
};
setFieldValue('category_id', categoryId);
},
[setFieldValue]
);
const handleFilterFlagChange = useCallback( const handleFilterFlagChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
@@ -213,13 +218,13 @@ const SuppliersTable = () => {
); );
// ===== FILTER HELPERS ===== // ===== FILTER HELPERS =====
const categoryIdValue = useMemo(() => { // const categoryIdValue = useMemo(() => {
if (!formik.values.category_id) return null; // if (!formik.values.category_id) return null;
return ( // return (
categoryOptions.find((opt) => opt.value === formik.values.category_id) || // categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
null // null
); // );
}, [formik.values.category_id, categoryOptions]); // }, [formik.values.category_id, categoryOptions]);
const flagValue = useMemo(() => { const flagValue = useMemo(() => {
if (formik.values.flag === null) return null; if (formik.values.flag === null) return null;
@@ -243,14 +248,6 @@ const SuppliersTable = () => {
} }
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]); }, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('suppliers-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -269,8 +266,7 @@ const SuppliersTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -330,6 +326,11 @@ const SuppliersTable = () => {
accessorKey: 'email', accessorKey: 'email',
header: 'Email', header: 'Email',
}, },
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{ {
accessorKey: 'address', accessorKey: 'address',
header: 'Alamat', header: 'Alamat',
@@ -491,13 +492,13 @@ const SuppliersTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio <SelectInputRadio
label='Kategori' label='Kategori'
placeholder='Pilih Kategori' placeholder='Pilih Kategori'
options={categoryOptions} options={categoryOptions}
value={categoryIdValue} value={formik.values.category}
onChange={handleFilterCategoryChange} onChange={handleFilterCategoryChange}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
@@ -517,13 +518,9 @@ const SuppliersTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='button' type='reset'
variant='soft' 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' 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 Reset Filter
</Button> </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({ export const SupplierFilterSchema = Yup.object().shape({
category_id: string().nullable(), category: Yup.object({
flag: boolean().nullable(), value: Yup.string().required(),
label: Yup.string().required(),
}).nullable(),
flag: Yup.boolean().nullable(),
}); });
export type SupplierFilterType = { export type SupplierFilterType = {
category_id: string | null; category?: OptionType<string>;
flag: boolean | null; flag: boolean | null;
}; };
@@ -31,6 +31,9 @@ export const SupplierFormSchema = Yup.object({
npwp: Yup.string() npwp: Yup.string()
.matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
.required('Nomor NPWP wajib diisi!'), .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() account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'), .required('Nomor rekening wajib diisi!'),
@@ -122,6 +122,7 @@ const SupplierForm = ({
email: initialValues?.email ?? '', email: initialValues?.email ?? '',
address: initialValues?.address ?? '', address: initialValues?.address ?? '',
npwp: initialValues?.npwp ?? '', npwp: initialValues?.npwp ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '', account_number: initialValues?.account_number ?? '',
due_date: initialValues?.due_date ?? 1, due_date: initialValues?.due_date ?? 1,
}; };
@@ -149,6 +150,7 @@ const SupplierForm = ({
email: values.email, email: values.email,
address: values.address, address: values.address,
npwp: values.npwp, npwp: values.npwp,
bank_name: values.bank_name,
account_number: values.account_number, account_number: values.account_number,
due_date: parseInt(values.due_date.toString()), due_date: parseInt(values.due_date.toString()),
}; };
@@ -368,6 +370,22 @@ const SupplierForm = ({
errorMessage={formik.errors.npwp} errorMessage={formik.errors.npwp}
readOnly={formType === 'detail'} 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 <TextInput
required required
label='Nomor Rekening' label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { UomApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
}; };
const UomsTable = () => { const UomsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -114,22 +109,16 @@ const UomsTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: searchValue, search: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', 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 [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -146,8 +135,7 @@ const UomsTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,13 +1,6 @@
'use client'; 'use client';
import { import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; 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 { WarehouseApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
WarehouseFilterSchema, WarehouseFilterSchema,
WarehouseFilterType, WarehouseFilterType,
@@ -120,9 +112,6 @@ const RowOptionsMenu = ({
}; };
const WarehousesTable = () => { const WarehousesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -141,6 +130,8 @@ const WarehousesTable = () => {
areaFilter: 'area_id', areaFilter: 'area_id',
activeProjectFlockFilter: 'active_project_flock', activeProjectFlockFilter: 'active_project_flock',
}, },
persist: true,
storeName: 'warehouses-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -149,27 +140,36 @@ const WarehousesTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<WarehouseFilterType>({ const formik = useFormik<WarehouseFilterType>({
initialValues: { initialValues: {
area_id: null, area_id: tableFilterState.areaFilter || null,
active_project_flock: false, active_project_flock:
tableFilterState.activeProjectFlockFilter === 'true',
}, },
validationSchema: WarehouseFilterSchema, validationSchema: WarehouseFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || ''); updateFilter('areaFilter', values.area_id || '', true);
updateFilter( updateFilter(
'activeProjectFlockFilter', 'activeProjectFlockFilter',
values.active_project_flock === true ? 'true' : '' values.active_project_flock === true ? 'true' : '',
true
); );
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); 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 ===== // ===== AREA OPTIONS =====
const { const {
@@ -243,26 +243,6 @@ const WarehousesTable = () => {
formik.validateForm(); 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 [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -281,8 +261,7 @@ const WarehousesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -507,7 +486,7 @@ const WarehousesTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Area' label='Area'
@@ -538,10 +517,7 @@ const WarehousesTable = () => {
type='button' type='button'
variant='soft' 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' 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={() => { onClick={formikResetHandler}
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </Button>
@@ -11,7 +11,6 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Dropdown from '@/components/Dropdown';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data'; 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 { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { useUiStore } from '@/stores/ui/ui.store';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -45,6 +43,7 @@ import {
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import NumberInput from '@/components/input/NumberInput';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
props, props,
@@ -59,8 +58,7 @@ const RowOptionsMenu = ({
detailClickHandler: (id: number) => void; detailClickHandler: (id: number) => void;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
// TODO: change this to real condition const showEditButton = props.row.original.approval?.step_number !== 2;
const showEditButton = true;
const showDeleteButton = showEditButton; const showDeleteButton = showEditButton;
@@ -149,7 +147,6 @@ const RowOptionsMenu = ({
}; };
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname(); const pathname = usePathname();
const isSuccess = useProjectFlockStore((s) => s.isSuccess); const isSuccess = useProjectFlockStore((s) => s.isSuccess);
@@ -175,6 +172,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
kandang_id: '', kandang_id: '',
category: '', category: '',
period: '', period: '',
area_name: '',
location_name: '',
kandang_name: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -186,7 +186,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
category: 'category', category: 'category',
period: 'period', period: 'period',
}, },
excludeKeysFromUrl: ['area_name', 'location_name', 'kandang_name'],
persist: true,
storeName: 'project-flock-table',
}); });
const router = useRouter(); const router = useRouter();
// ===== State ===== // ===== State =====
@@ -207,8 +211,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const { const {
isChickinApproveModalOpen, isChickinApproveModalOpen,
isChickinApproveLoading, isChickinApproveLoading,
@@ -258,6 +261,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', values.kandang_id || ''); updateFilter('kandang_id', values.kandang_id || '');
updateFilter('category', values.category || ''); updateFilter('category', values.category || '');
updateFilter('period', values.period || ''); 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(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
@@ -267,6 +282,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', ''); updateFilter('kandang_id', '');
updateFilter('category', ''); updateFilter('category', '');
updateFilter('period', ''); updateFilter('period', '');
updateFilter('area_name', '');
updateFilter('location_name', '');
updateFilter('kandang_name', '');
setFilterAreaId(undefined); setFilterAreaId(undefined);
setFilterLocationId(undefined); setFilterLocationId(undefined);
filterModal.closeModal(); filterModal.closeModal();
@@ -308,40 +326,55 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[] []
); );
const periodOptions = useMemo(
() => [
{ value: '1', label: 'Periode 1' },
{ value: '2', label: 'Periode 2' },
],
[]
);
// ===== FILTER HELPERS ===== // ===== FILTER HELPERS =====
const areaValue = useMemo(() => { const areaValue = useMemo(() => {
if (!formik.values.area_id) return null; if (!formik.values.area_id) return null;
return ( const found = areaOptions.find(
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) || (opt) => String(opt.value) === formik.values.area_id
null
); );
}, [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(() => { const locationValue = useMemo(() => {
if (!formik.values.location_id) return null; if (!formik.values.location_id) return null;
return ( const found = locationOptions.find(
locationOptions.find( (opt) => String(opt.value) === formik.values.location_id
(opt) => String(opt.value) === formik.values.location_id
) || null
); );
}, [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(() => { const kandangValue = useMemo(() => {
if (!formik.values.kandang_id) return null; if (!formik.values.kandang_id) return null;
return ( const found = kandangOptions.find(
kandangOptions.find( (opt) => String(opt.value) === formik.values.kandang_id
(opt) => String(opt.value) === formik.values.kandang_id
) || null
); );
}, [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(() => { const categoryValue = useMemo(() => {
if (!formik.values.category) return null; if (!formik.values.category) return null;
@@ -351,13 +384,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}, [formik.values.category, categoryOptions]); }, [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 ===== // ===== FILTER DEPENDENCY HANDLERS =====
const handleFilterAreaChange = (area: OptionType | null) => { const handleFilterAreaChange = (area: OptionType | null) => {
const areaId = area?.value ? String(area.value) : undefined; const areaId = area?.value ? String(area.value) : undefined;
@@ -426,18 +452,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
setRowSelection({}); setRowSelection({});
}; };
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('project-flock-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value); updateFilter('search', e.target.value, true);
updateFilter('search', e.target.value);
}; };
const confirmApprovalHandler = async ( const confirmApprovalHandler = async (
notes: string, notes: string,
approvalAction: 'APPROVED' | 'REJECTED' approvalAction: 'APPROVED' | 'REJECTED'
@@ -555,6 +574,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
price: budget.price, price: budget.price,
total_price: budget.qty * budget.price, total_price: budget.qty * budget.price,
})) || [], })) || [],
periode: createdProjectFlock.period ?? '-',
} as ProjectFlockFormValues; } as ProjectFlockFormValues;
}, [createdProjectFlock]); }, [createdProjectFlock]);
@@ -777,14 +797,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[] []
); );
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
toast.error('Not implemented yet!');
setIsLoadingExportingToExcel(false);
};
const bulkApproveClickHandler = () => { const bulkApproveClickHandler = () => {
setApprovalAction('APPROVED'); setApprovalAction('APPROVED');
confirmModal.openModal(); confirmModal.openModal();
@@ -973,55 +985,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<ButtonFilter <ButtonFilter
values={tableFilterState} values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']} excludeFields={[
'page',
'pageSize',
'search',
'area_name',
'location_name',
'kandang_name',
]}
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className='px-3 py-2.5' 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>
</div> </div>
@@ -1350,18 +1324,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
isClearable={true} isClearable={true}
/> />
<SelectInputRadio <NumberInput
label='Periode' label='Periode'
placeholder='Pilih Periode' name='period'
options={periodOptions} placeholder='Masukkan Periode'
value={periodValue} value={formik.values.period ?? ''}
onChange={(val) => { onChange={formik.handleChange}
if (!Array.isArray(val)) { onBlur={formik.handleBlur}
formik.setFieldValue('period', val?.value || null);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable
/> />
</div> </div>
@@ -26,6 +26,7 @@ type ProjectFlockFormSchemaType = {
label: string; label: string;
} | null; } | null;
location_id: number; location_id: number;
periode: number | string;
kandang_ids: number[]; kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[]; project_budgets: ProjectFlockBudgetsSchemaType[];
}; };
@@ -109,6 +110,12 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
.min(1, 'Lokasi wajib diisi!') .min(1, 'Lokasi wajib diisi!')
.required('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() kandang_ids: Yup.array()
.of(Yup.number().required('Kandang tidak valid!')) .of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!') .min(1, 'Minimal harus ada 1 kandang!')
@@ -152,6 +152,10 @@ export const ProjectFlockFormConfirmationTable = ({
label: 'Standar Produksi', label: 'Standar Produksi',
value: projectFlockForm?.production_standard?.label ?? '-', value: projectFlockForm?.production_standard?.label ?? '-',
}, },
{
label: 'Periode',
value: projectFlockForm?.periode ?? '-',
},
{ {
label: 'Informasi Kandang', label: 'Informasi Kandang',
value: '', value: '',
@@ -261,7 +265,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingFlocks, isLoadingOptions: isLoadingFlocks,
options: optionsFlock, options: optionsFlock,
loadMore: loadMoreFlock, loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', { } = useSelect(FlockApi.basePath, 'id', 'name', 'search', {
project_category: selectedCategory, project_category: selectedCategory,
location_id: selectedLocation, location_id: selectedLocation,
area_id: selectedArea, area_id: selectedArea,
@@ -279,7 +283,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingLocations, isLoadingOptions: isLoadingLocations,
setInputValue: setInputValueLocation, setInputValue: setInputValueLocation,
loadMore: loadMoreLocation, loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', { } = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
area_id: area_id:
selectedArea != '' selectedArea != ''
? selectedArea ? selectedArea
@@ -291,7 +295,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingProductionStandards, isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard, setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard, loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', { } = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', {
project_category: selectedCategory, project_category: selectedCategory,
}); });
@@ -307,7 +311,7 @@ const ProjectFlockForm = ({
} = useSWR(kandangUrl, KandangApi.getAllFetcher); } = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR( const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
`${selectedFlock?.toString()}/periods`, selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined,
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string)) () => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
); );
@@ -529,6 +533,7 @@ const ProjectFlockForm = ({
kandang_ids: initialValues?.kandangs?.map( kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id (k: Kandang) => k.id
) as number[], ) as number[],
periode: initialValues?.period ?? '',
project_budgets: initialValues?.project_budgets?.map((budget) => { project_budgets: initialValues?.project_budgets?.map((budget) => {
return { return {
nonstock: { nonstock: {
@@ -568,6 +573,7 @@ const ProjectFlockForm = ({
category: values.category as string, category: values.category as string,
production_standard_id: values.production_standard_id as number, production_standard_id: values.production_standard_id as number,
location_id: values.location_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[], kandang_ids: values.kandang_ids as number[],
project_budgets: values.project_budgets.flatMap((budget) => { project_budgets: values.project_budgets.flatMap((budget) => {
return { return {
@@ -793,6 +799,7 @@ const ProjectFlockForm = ({
formik.values.kandang_ids?.includes(kandang.id) formik.values.kandang_ids?.includes(kandang.id)
)?.period )?.period
: undefined; : undefined;
const inputPeriod = const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod; (initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
@@ -1022,12 +1029,18 @@ const ProjectFlockForm = ({
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
<NumberInput <NumberInput
name='period' name='periode'
label='Periode' label='Periode'
disabled
readOnly
placeholder='Periode Flock' 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> </div>
@@ -1,12 +1,6 @@
'use client'; 'use client';
import React, { import React, { useCallback, useState, useMemo, useEffect } from 'react';
useCallback,
useState,
useMemo,
useEffect,
useRef,
} from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
@@ -18,9 +12,11 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput'; import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
import Tooltip from '@/components/Tooltip';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { AreaApi } from '@/services/api/master-data'; import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
@@ -36,16 +32,16 @@ import {
import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton'; import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording'; import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import { useUiStore } from '@/stores/ui/ui.store';
import { usePathname } from 'next/navigation';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
// ===== STATUS BADGE UTILITIES ===== // ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = { const statusTextMap: Record<string, string> = {
@@ -72,6 +68,26 @@ const getStatusBadgeColor = (status: string): Color => {
return statusBadgeColorMap[normalizedStatus] || 'neutral'; 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 = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
props, props,
@@ -105,30 +121,75 @@ const RowOptionsMenu = ({
}; };
const isRecordingEditable = (recording: Recording) => { const isRecordingEditable = (recording: Recording) => {
if (recording.project_flock?.project_flock_category === 'GROWING') { const isGrowingCategory =
if (recording.transfer_executed) { recording.project_flock?.project_flock_category === 'GROWING';
return false; const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
} if (isGrowingLockedByLaying) {
return recording.population_can_change === true; return false;
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
const restriction = getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
if (restriction.isLocked) {
return false;
} }
return true; return true;
}; };
const getRecordingRestrictionInfo = (recording: Recording) => {
const isGrowingCategory =
recording.project_flock?.project_flock_category === 'GROWING';
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
if (isGrowingLockedByLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing tidak dapat diubah karena sudah masuk fase laying dan dipakai pada recording laying',
};
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
return getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
};
const isApproved = isRecordingApproved(props.row.original); const isApproved = isRecordingApproved(props.row.original);
const isRejected = isRecordingRejected(props.row.original); const isRejected = isRecordingRejected(props.row.original);
const isEditable = isRecordingEditable(props.row.original); const isEditable = isRecordingEditable(props.row.original);
const restrictionInfo = getRecordingRestrictionInfo(props.row.original);
return ( return (
<div className='relative'> <div className='relative'>
<PopoverButton <Tooltip
tabIndex={0} content={restrictionInfo.isLocked ? restrictionInfo.lockReason : ''}
variant='ghost' position='top'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
> >
<Icon icon='material-symbols:more-vert' width={16} height={16} /> <PopoverButton
</PopoverButton> tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
className={restrictionInfo.isLocked ? 'text-error' : ''}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
</Tooltip>
<PopoverContent <PopoverContent
id={popoverId} id={popoverId}
@@ -218,80 +279,111 @@ const RowOptionsMenu = ({
}; };
const RecordingTable = () => { const RecordingTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, 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: { initial: {
search: '', search: '',
areaFilter: '', areaFilter: null,
locationFilter: '', locationFilter: null,
kandangFilter: '', projectFlockFilter: null,
projectFlockKandangFilter: '', kandangFilter: null,
projectFlockKandangFilter: null,
approvalStatusFilter: null,
projectFlockCategoryFilter: null,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
search: 'search', search: 'search',
areaFilter: 'area_id',
locationFilter: 'location_id',
projectFlockFilter: 'project_flock_id',
kandangFilter: 'kandang_id', kandangFilter: 'kandang_id',
projectFlockKandangFilter: 'project_flock_kandang_id', projectFlockKandangFilter: 'project_flock_kandang_id',
approvalStatusFilter: 'approval_status',
projectFlockCategoryFilter: 'project_flock_category',
}, },
});
useEffect(() => { persist: true,
updateFilter('search', searchValue); storeName: 'recording-table',
}, [searchValue, updateFilter]); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
const filterModal = useModal(); 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 ===== // ===== FORMIK SETUP =====
const formik = useFormik<RecordingFilterType>({ const formik = useFormik<RecordingFilterType>({
initialValues: { initialValues: {
area_id: null, area_id: tableFilterState.areaFilter,
location_id: null, location_id: tableFilterState.locationFilter,
kandang_id: null, project_flock_id: tableFilterState.projectFlockFilter,
project_flock_kandang_id: null, kandang_id: tableFilterState.kandangFilter,
project_flock_kandang_id: tableFilterState.projectFlockKandangFilter,
approval_status: tableFilterState.approvalStatusFilter,
project_flock_category: tableFilterState.projectFlockCategoryFilter,
}, },
validationSchema: RecordingFilterSchema, validationSchema: RecordingFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || ''); updateFilter('areaFilter', values.area_id, true);
updateFilter('locationFilter', values.location_id || ''); updateFilter('locationFilter', values.location_id, true);
updateFilter('kandangFilter', values.kandang_id || ''); updateFilter('projectFlockFilter', values.project_flock_id, true);
updateFilter('kandangFilter', values.kandang_id, true);
updateFilter( updateFilter(
'projectFlockKandangFilter', '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(); filterModal.closeModal();
setSubmitting(false); 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 [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) => const selectedRowIds = Object.keys(rowSelection).map((item) =>
@@ -305,9 +397,16 @@ const RecordingTable = () => {
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
const [, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const singleDeleteModal = useModal(); const singleDeleteModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
const rejectModal = useModal(); const rejectModal = useModal();
const exportProgressInputModal = useModal();
const { const {
data: recordings, data: recordings,
@@ -319,13 +418,6 @@ const RecordingTable = () => {
); );
// ===== LOCATION, AREA, KANDANG OPTIONS ===== // ===== LOCATION, AREA, KANDANG OPTIONS =====
const locationParams = useMemo(() => {
if (filterLocationAreaId) {
return { area_id: filterLocationAreaId };
}
return undefined;
}, [filterLocationAreaId]);
const { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
@@ -336,7 +428,9 @@ const RecordingTable = () => {
'id', 'id',
'name', 'name',
'search', 'search',
locationParams {
area_id: String(formik.values.area_id?.value),
}
); );
const { const {
@@ -351,13 +445,6 @@ const RecordingTable = () => {
'search' 'search'
); );
const projectFlockParams = useMemo(() => {
if (filterProjectFlockLocationId) {
return { location_id: filterProjectFlockLocationId };
}
return undefined;
}, [filterProjectFlockLocationId]);
const { const {
setInputValue: setProjectFlockInputValue, setInputValue: setProjectFlockInputValue,
options: projectFlockOptions, options: projectFlockOptions,
@@ -369,34 +456,41 @@ const RecordingTable = () => {
'id', 'id',
'flock_name', 'flock_name',
'search', 'search',
projectFlockParams {
location_id: String(formik.values.location_id?.value),
}
); );
const kandangOptions = useMemo(() => { const kandangOptions = useMemo(() => {
if (!filterProjectFlock || !projectFlocksRawData) return []; if (!project_flock_id || !projectFlocksRawData) return [];
if (!isResponseSuccess(projectFlocksRawData)) return []; if (!isResponseSuccess(projectFlocksRawData)) return [];
const data = projectFlocksRawData.data as ProjectFlock[]; const data = projectFlocksRawData.data as ProjectFlock[];
const selectedProjectFlockData = data.find( const selectedProjectFlockData = data.find((pf) =>
(pf) => pf.id === filterProjectFlock.value pf.id === formik.values.project_flock_id?.value
? Number(formik.values.project_flock_id.value)
: 0
); );
if (!selectedProjectFlockData?.kandangs) return []; if (!selectedProjectFlockData?.kandangs) return [];
return selectedProjectFlockData.kandangs.map((k) => ({ return selectedProjectFlockData.kandangs.map((k) => ({
value: k.id, value: k.id,
label: k.name || '', label: k.name || '',
})); }));
}, [filterProjectFlock, projectFlocksRawData]); }, [project_flock_id, projectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP ===== // ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => { const projectFlockKandangLookupUrl = useMemo(() => {
if (!filterProjectFlock || !filterKandang) return null; if (!project_flock_id?.value || !kandang_id?.value) return null;
const params = new URLSearchParams({ const params = new URLSearchParams({
project_flock_id: filterProjectFlock.value.toString(), project_flock_id: project_flock_id.value.toString(),
kandang_id: filterKandang.value.toString(), kandang_id: kandang_id.value.toString(),
}); });
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [filterProjectFlock, filterKandang]); }, [project_flock_id, kandang_id]);
const { data: projectFlockKandangLookupData } = useSWR( const { data: projectFlockKandangLookupData } = useSWR(
projectFlockKandangLookupUrl, projectFlockKandangLookupUrl,
@@ -418,118 +512,45 @@ const RecordingTable = () => {
? projectFlockKandangLookupData.data ? projectFlockKandangLookupData.data
: undefined; : undefined;
const formikRef = useRef(formik);
useEffect(() => {
formikRef.current = formik;
});
useEffect(() => { useEffect(() => {
if (projectFlockKandangLookup?.id) { if (projectFlockKandangLookup?.id) {
const pfkId = String(projectFlockKandangLookup.id); const pfkId = String(projectFlockKandangLookup.id);
setFilterProjectFlockKandangId(projectFlockKandangLookup.id); formik.setFieldValue('project_flock_kandang_id', pfkId);
formikRef.current.setFieldValue('project_flock_kandang_id', pfkId);
} else { } else {
setFilterProjectFlockKandangId(undefined); formik.setFieldValue('project_flock_kandang_id', null);
formikRef.current.setFieldValue('project_flock_kandang_id', null);
} }
}, [projectFlockKandangLookup]); }, [projectFlockKandangLookup]);
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterAreaChange = useCallback( const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
(val: OptionType | OptionType[] | null) => { formik.setFieldValue('area_id', val);
const area = val as OptionType | null; formik.setFieldValue('location_id', null);
const areaId = area?.value ? String(area.value) : null; formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
formik.setFieldValue('area_id', areaId); const handleFilterLocationChange = (
formik.setFieldValue('location_id', null); val: OptionType | OptionType[] | null
formik.setFieldValue('kandang_id', null); ) => {
formik.setFieldValue('project_flock_kandang_id', 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); const handleFilterProjectFlockChange = (
setFilterLocation(null); val: OptionType | OptionType[] | null
setFilterProjectFlock(null); ) => {
setFilterKandang(null); formik.setFieldValue('project_flock_id', val);
setFilterLocationAreaId(areaId || ''); formik.setFieldValue('kandang_id', null);
setFilterProjectFlockLocationId(''); formik.setFieldValue('project_flock_kandang_id', null);
}, };
[formik]
);
const handleFilterLocationChange = useCallback( const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => {
(val: OptionType | OptionType[] | null) => { formik.setFieldValue('kandang_id', val);
const location = val as OptionType | null; formik.setFieldValue('project_flock_kandang_id', 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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -537,35 +558,24 @@ const RecordingTable = () => {
formik.validateForm(); formik.validateForm();
}; };
const isRecordingApproved = useCallback((recording: Recording): boolean => { const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
return ( updateFilter('search', e.target.value, true);
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 singleDeleteHandler = async () => { const singleDeleteHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await RecordingApi.delete(selectedRecording?.id as number); const response = await RecordingApi.delete(selectedRecording?.id as number);
refreshRecordings();
singleDeleteModal.closeModal(); singleDeleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Recording!');
refreshRecordings();
} else {
toast.error(response?.message || 'Failed to delete Recording');
}
}; };
const approveHandler = async (notes: string) => { const approveHandler = async (notes: string) => {
@@ -634,6 +644,68 @@ const RecordingTable = () => {
}); });
}, [selectedRowIds, recordings, isRecordingApproved]); }, [selectedRowIds, recordings, isRecordingApproved]);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
await RecordingApi.exportToExcel(getTableFilterQueryString());
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(() => { useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) { if (isResponseSuccess(recordings) && recordings.data) {
const newSelection: Record<string, boolean> = {}; const newSelection: Record<string, boolean> = {};
@@ -761,11 +833,30 @@ const RecordingTable = () => {
{ {
header: 'Kategori', header: 'Kategori',
cell: (props) => { cell: (props) => {
const isTransition = props.row.original.is_transition;
const category = const category =
props.row.original.project_flock?.project_flock_category; props.row.original.project_flock?.project_flock_category ||
if (!category) return '-'; 'GROWING';
const color = category === 'LAYING' ? 'info' : 'warning'; const color = category === 'LAYING' ? 'info' : 'warning';
return <StatusBadge color={color} text={formatTitleCase(category)} />;
const isGrowingLocked =
category === 'GROWING' && props.row.original.is_laying;
return (
<div className='flex flex-col gap-1'>
<StatusBadge color={color} text={formatTitleCase(category)} />
{isTransition && (
<span className='text-xs text-warning font-medium'>
(Transisi)
</span>
)}
{isGrowingLocked && (
<span className='text-xs text-error font-medium'>
(Penguncian)
</span>
)}
</div>
);
}, },
}, },
{ {
@@ -775,7 +866,8 @@ const RecordingTable = () => {
<> <>
<span> <span>
{props.row.original.day} (Minggu ke- {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> </span>
</> </>
); );
@@ -1021,7 +1113,7 @@ const RecordingTable = () => {
return ( return (
<div className='text-center'> <div className='text-center'>
{value !== null && value !== undefined {value !== null && value !== undefined
? `${value.toFixed(2)}%` ? `${value.toFixed(2)} butir`
: '-'} : '-'}
</div> </div>
); );
@@ -1037,7 +1129,7 @@ const RecordingTable = () => {
return ( return (
<div className='text-center text-gray-600'> <div className='text-center text-gray-600'>
{value !== null && value !== undefined {value !== null && value !== undefined
? `${value.toFixed(2)}%` ? `${value.toFixed(2)} btr`
: '-'} : '-'}
</div> </div>
); );
@@ -1242,6 +1334,60 @@ const RecordingTable = () => {
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
<Dropdown
align='end'
direction='bottom'
trigger={
<Button
variant='outline'
color='none'
className={cn(
'px-3 py-2.5 rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon
width={20}
height={20}
icon='heroicons:cloud-arrow-down'
/>
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon
width={14}
height={14}
icon='heroicons:chevron-down'
/>
</div>
</Button>
}
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
>
<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>
<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>
</div> </div>
@@ -1319,13 +1465,13 @@ const RecordingTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Area' label='Area'
placeholder='Pilih Area' placeholder='Pilih Area'
options={areaOptions} options={areaOptions}
value={areaIdValue} value={formik.values.area_id}
onChange={handleFilterAreaChange} onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions} isLoading={isLoadingAreaOptions}
@@ -1338,13 +1484,13 @@ const RecordingTable = () => {
label='Lokasi' label='Lokasi'
placeholder='Pilih Lokasi' placeholder='Pilih Lokasi'
options={locationOptions} options={locationOptions}
value={locationIdValue} value={formik.values.location_id}
onChange={handleFilterLocationChange} onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
isClearable isClearable
onMenuScrollToBottom={loadMoreLocations} onMenuScrollToBottom={loadMoreLocations}
isDisabled={!filterArea} isDisabled={!formik.values.area_id?.value}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
@@ -1352,13 +1498,13 @@ const RecordingTable = () => {
label='Project Flock' label='Project Flock'
placeholder='Pilih Project Flock' placeholder='Pilih Project Flock'
options={projectFlockOptions} options={projectFlockOptions}
value={projectFlockIdValue} value={formik.values.project_flock_id}
onChange={handleFilterProjectFlockChange} onChange={handleFilterProjectFlockChange}
onInputChange={setProjectFlockInputValue} onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlocks} isLoading={isLoadingProjectFlocks}
isClearable isClearable
onMenuScrollToBottom={loadMoreProjectFlocks} onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!filterLocation} isDisabled={!formik.values.location_id?.value}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
@@ -1366,11 +1512,35 @@ const RecordingTable = () => {
label='Kandang' label='Kandang'
placeholder='Pilih Kandang' placeholder='Pilih Kandang'
options={kandangOptions} options={kandangOptions}
value={kandangIdValue} value={formik.values.kandang_id}
onChange={handleFilterKandangChange} 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 isClearable
isDisabled={!filterProjectFlock}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div>
@@ -1378,30 +1548,16 @@ const RecordingTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='button' type='reset'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
<Button <Button
type='submit' type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={ disabled={!formik.isValid || formik.isSubmitting}
!formik.isValid ||
formik.isSubmitting ||
!formik.values.kandang_id
}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -1424,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 <ConfirmationModalWithNotes
ref={approveModal.ref} ref={approveModal.ref}
type='success' 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({ export const RecordingFilterSchema = Yup.object().shape({
area_id: string().nullable(), area_id: Yup.object({
location_id: string().nullable(), value: Yup.number().nullable(),
kandang_id: string().nullable(), label: Yup.string().nullable(),
project_flock_kandang_id: 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 = { export type RecordingFilterType = {
area_id: string | null; area_id: OptionType<number> | null;
location_id: string | null; location_id: OptionType<number> | null;
kandang_id: string | null; project_flock_id: OptionType<number> | null;
project_flock_kandang_id: string | 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, CreateGrowingRecordingPayload,
CreateLayingRecordingPayload, CreateLayingRecordingPayload,
CreateEggPayload, CreateEggPayload,
RecordingStock,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type RecordingGrowingFormSchemaType = { type RecordingGrowingFormSchemaType = {
record_date: string; record_date: string;
@@ -29,82 +31,101 @@ type RecordingGrowingFormSchemaType = {
} | null; } | null;
project_flock_kandang_id: number; project_flock_kandang_id: number;
stocks: { stocks: {
product_warehouse_id?: number; product_warehouse_id:
qty?: number | string; | {
value: number;
label: string;
}
| undefined;
qty: number | string;
}[]; }[];
depletions: { depletions: {
product_warehouse_id?: number; product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string; qty?: number | string;
}[]; }[];
}; };
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: { eggs: {
product_warehouse_id?: number; product_warehouse_id?: {
value: number;
label: string;
} | null;
qty?: number | string; qty?: number | string;
weight?: number | string; weight?: number | string;
}[]; }[];
}; };
export type StockSchema = { export type StockSchema = {
product_warehouse_id: number; product_warehouse_id: {
value: number;
label: string;
};
qty: number | string; qty: number | string;
}; };
export type DepletionSchema = { export type DepletionSchema = {
product_warehouse_id?: number; product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string; qty?: number | string;
}; };
export type EggSchema = { export type EggSchema = {
product_warehouse_id?: number; product_warehouse_id?: {
value: number;
label: string;
} | null;
qty?: number | string; qty?: number | string;
weight?: number | string; weight?: number | string;
}; };
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({ 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!') .required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!') .typeError('Produk wajib diisi!'),
.typeError('Produk harus berupa angka!'),
qty: Yup.number() qty: Yup.number()
.required('Jumlah penggunaan wajib diisi!') .required('Jumlah penggunaan wajib diisi!')
.min(1, 'Jumlah penggunaan tidak boleh 0!') .moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
.typeError('Jumlah penggunaan harus berupa angka!'),
});
const OptionalStockObjectSchema: Yup.ObjectSchema<{
product_warehouse_id?: number;
qty?: number | string;
}> = Yup.object({
product_warehouse_id: Yup.number()
.optional()
.typeError('Produk harus berupa angka!'),
qty: Yup.number()
.optional()
.typeError('Jumlah penggunaan harus berupa angka!'), .typeError('Jumlah penggunaan harus berupa angka!'),
}); });
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = 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() .optional()
.typeError('Depletions harus berupa angka!'), .nullable(),
source_product_warehouse_id: Yup.number()
.optional()
.typeError('Gudang sumber harus berupa angka!'),
qty: Yup.number() qty: Yup.number()
.optional() .optional()
.typeError('Jumlah depletions harus berupa angka!'), .typeError('Jumlah depletions harus berupa angka!'),
}); });
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = 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() .optional()
.typeError('Kondisi telur harus berupa angka!'), .nullable(),
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'), qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'), weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
}); });
export const RecordingGrowingFormSchema = ( export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
isTransitioningToLaying = false
): Yup.ObjectSchema<RecordingGrowingFormSchemaType> =>
Yup.object({ Yup.object({
record_date: Yup.string() record_date: Yup.string()
.required('Tanggal recording wajib diisi!') .required('Tanggal recording wajib diisi!')
@@ -164,24 +185,20 @@ export const RecordingGrowingFormSchema = (
return true; return true;
} }
), ),
stocks: isTransitioningToLaying stocks: Yup.array()
? Yup.array().of(OptionalStockObjectSchema).default([]) .of(StockObjectSchema)
: Yup.array() .min(1, 'Minimal harus ada 1 data stok!')
.of(StockObjectSchema) .required('Data stok wajib diisi!'),
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array().of(DepletionObjectSchema).default([]), depletions: Yup.array().of(DepletionObjectSchema).default([]),
}); });
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> = export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
RecordingGrowingFormSchema().shape({ RecordingGrowingFormSchema.shape({
eggs: Yup.array().of(EggObjectSchema).default([]), eggs: Yup.array().of(EggObjectSchema).default([]),
}); });
export const UpdateRecordingGrowingFormSchema = ( export const UpdateRecordingGrowingFormSchema =
isTransitioningToLaying = false RecordingGrowingFormSchema.shape({
) =>
RecordingGrowingFormSchema(isTransitioningToLaying).shape({
location_id: Yup.number().nullable().optional(), location_id: Yup.number().nullable().optional(),
project_flock_id: Yup.number().nullable().optional(), project_flock_id: Yup.number().nullable().optional(),
kandang_id: Yup.number().nullable().optional(), kandang_id: Yup.number().nullable().optional(),
@@ -211,13 +228,10 @@ export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({
.required('Project Flock Kandang wajib diisi!'), .required('Project Flock Kandang wajib diisi!'),
}); });
type RecordingGrowingFormSchemaFn = ReturnType< export type RecordingGrowingFormValues = Yup.InferType<
typeof RecordingGrowingFormSchema typeof RecordingGrowingFormSchema
>; >;
export type RecordingGrowingFormValues =
Yup.InferType<RecordingGrowingFormSchemaFn>;
export type RecordingLayingFormValues = Yup.InferType< export type RecordingLayingFormValues = Yup.InferType<
typeof RecordingLayingFormSchema typeof RecordingLayingFormSchema
>; >;
@@ -264,14 +278,18 @@ export const getRecordingGrowingFormInitialValues = (
initialValues?.project_flock?.project_flock_kandang_id ?? initialValues?.project_flock?.project_flock_kandang_id ??
0, 0,
stocks: initialValues?.stocks?.map((stock) => ({ 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: qty:
(stock as { qty?: number; usage_amount?: number }).qty || (stock as RecordingStock).qty ||
(stock as { qty?: number; usage_amount?: number }).usage_amount || ((stock as RecordingStock).usage_amount || 0) +
((stock as RecordingStock).pending_qty || 0) ||
'', '',
})) ?? [ })) ?? [
{ {
product_warehouse_id: 0, product_warehouse_id: undefined,
qty: '', qty: '',
}, },
], ],
@@ -279,12 +297,16 @@ export const getRecordingGrowingFormInitialValues = (
( (
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0] 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, qty: depletion.qty,
}) })
) ?? [ ) ?? [
{ {
product_warehouse_id: 0, product_warehouse_id: undefined,
qty: '', qty: '',
}, },
], ],
@@ -296,12 +318,15 @@ export const getRecordingLayingFormInitialValues = (
...getRecordingGrowingFormInitialValues(initialValues), ...getRecordingGrowingFormInitialValues(initialValues),
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({ 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, qty: egg.qty,
weight: egg.weight, weight: egg.weight,
})) ?? [ })) ?? [
{ {
product_warehouse_id: 0, product_warehouse_id: null,
qty: '', qty: '',
weight: '', weight: '',
}, },
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
export type RecordingRestriction = {
canEditStock: boolean;
canEditDepletion: boolean;
canEditEgg: boolean;
isLocked: boolean;
lockReason?: string;
};
export const getRecordingRestriction = (
isLaying: boolean,
isTransition: boolean,
currentIsLaying?: boolean
): RecordingRestriction => {
if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true;
if (isLayingKandangInTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
} else {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
}
if (!isLaying && !isTransition && currentIsLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
if (!isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
}
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
};
};
@@ -40,6 +40,9 @@ const TransferToLayingDetailModal = () => {
? transferToLayingResponse.data ? transferToLayingResponse.data
: undefined; : undefined;
const isTransferToLayingApproved =
transferToLaying?.approval.step_number === 2;
const { data: transferToLayingApprovalResponse } = useSWR( const { data: transferToLayingApprovalResponse } = useSWR(
transferToLayingId transferToLayingId
? ['approval-transfer-to-laying', transferToLayingId] ? ['approval-transfer-to-laying', transferToLayingId]
@@ -55,9 +58,9 @@ const TransferToLayingDetailModal = () => {
const detailModal = useModal(); const detailModal = useModal();
const totalEnteredChickenForTransfer = const maxSourceQuantity =
transferToLaying?.sources.reduce( transferToLaying?.sources.reduce(
(acc, item) => acc + Number(item.qty), (acc, item) => acc + Number(item.product_warehouse.quantity),
0 0
) ?? 0; ) ?? 0;
@@ -67,8 +70,9 @@ const TransferToLayingDetailModal = () => {
0 0
) ?? 0; ) ?? 0;
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
const totalAvailableChickenForTransfer = const totalAvailableChickenForTransfer =
totalEnteredChickenForTransfer - totalTransferedChicken; maxSourceQuantity - totalTransferedChicken;
const closeModalHandler = (shouldPushToRoute: boolean = true) => { const closeModalHandler = (shouldPushToRoute: boolean = true) => {
if (shouldPushToRoute) { if (shouldPushToRoute) {
@@ -161,11 +165,34 @@ const TransferToLayingDetailModal = () => {
{/* Source Kandang */} {/* Source Kandang */}
<div className='flex flex-col'> <div className='flex flex-col'>
<span className='w-full py-2 text-xs font-semibold'> <span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
Kandang Asal{' '} <span className='text-nowrap'>
<span className='tooltip tooltip-error' data-tip='required'> Kandang Asal{' '}
<span className='text-error'> *</span> <span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</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> </span>
{transferToLaying?.sources.length === 0 && ( {transferToLaying?.sources.length === 0 && (
@@ -225,21 +252,6 @@ const TransferToLayingDetailModal = () => {
<span className='text-error'> *</span> <span className='text-error'> *</span>
</span> </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> </span>
{transferToLaying?.targets.length === 0 && ( {transferToLaying?.targets.length === 0 && (
@@ -304,7 +316,7 @@ const TransferToLayingDetailModal = () => {
readOnly readOnly
errorMessage={ errorMessage={
totalAvailableChickenForTransfer < 0 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 { OptionType, useSelect } from '@/components/input/SelectInput';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production';
import { Flock } from '@/types/api/master-data/flock'; import { Flock } from '@/types/api/master-data/flock';
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
import { import {
TransferToLayingFilterSchema, TransferToLayingFilterSchema,
TransferToLayingFilterValues, TransferToLayingFilterValues,
@@ -21,12 +20,14 @@ import {
interface TransferToLayingFilterModal { interface TransferToLayingFilterModal {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: TransferToLayingFilter) => void; initialValues?: Partial<TransferToLayingFilterValues>;
onSubmit?: (values: TransferToLayingFilterValues) => void;
onReset?: () => void; onReset?: () => void;
} }
const TransferToLayingFilterModal = ({ const TransferToLayingFilterModal = ({
ref, ref,
initialValues: initialValuesProp,
onSubmit, onSubmit,
onReset, onReset,
}: TransferToLayingFilterModal) => { }: TransferToLayingFilterModal) => {
@@ -86,28 +87,16 @@ const TransferToLayingFilterModal = ({
const formik = useFormik<TransferToLayingFilterValues>({ const formik = useFormik<TransferToLayingFilterValues>({
initialValues: { initialValues: {
startDate: '', startDate: initialValuesProp?.startDate ?? '',
endDate: '', endDate: initialValuesProp?.endDate ?? '',
flockSource: [], flockSource: initialValuesProp?.flockSource ?? [],
flockDestination: [], flockDestination: initialValuesProp?.flockDestination ?? [],
status: [], status: initialValuesProp?.status ?? [],
}, },
enableReinitialize: true,
validationSchema: TransferToLayingFilterSchema, validationSchema: TransferToLayingFilterSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
const formattedValues = { onSubmit?.(values);
...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);
closeModalHandler(); closeModalHandler();
}, },
onReset: () => { onReset: () => {
@@ -223,6 +223,8 @@ const TransferToLayingFormModal = () => {
}, },
}); });
const { flockSource: formikFlockSource } = formik.values;
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState< const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState<
@@ -455,13 +457,13 @@ const TransferToLayingFormModal = () => {
useEffect(() => { useEffect(() => {
if (isResponseSuccess(flockSourceRawData)) { if (isResponseSuccess(flockSourceRawData)) {
const selectedFlockSourceRawData = flockSourceRawData.data.find( const currentSelectedFlockSourceRawData = flockSourceRawData.data.find(
(item) => item.id === formik.values.flockSource?.value (item) => item.id === formik.values.flockSource?.value
); );
setSelectedFlockSourceRawData(selectedFlockSourceRawData); setSelectedFlockSourceRawData(currentSelectedFlockSourceRawData);
} }
}, [flockSourceRawData]); }, [flockSourceRawData, formikFlockSource]);
useEffect(() => { useEffect(() => {
formik.setFieldValue('totalQuantity', totalTransferedChicken); formik.setFieldValue('totalQuantity', totalTransferedChicken);
@@ -625,6 +627,7 @@ const TransferToLayingFormModal = () => {
> >
<div className='flex flex-row items-center gap-3'> <div className='flex flex-row items-center gap-3'>
<input <input
id={`flock-source-kandang-${item.project_flock_kandang_id}`}
type='radio' type='radio'
name='flockSourceKandang' name='flockSourceKandang'
value={item.project_flock_kandang_id} value={item.project_flock_kandang_id}
@@ -637,13 +640,14 @@ const TransferToLayingFormModal = () => {
/> />
<label <label
htmlFor={`flock-source-kandang-${item.project_flock_kandang_id}`}
className={cn('text-sm text-base-content/50', { className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable, 'cursor-pointer': isAvailable,
'cursor-not-allowed opacity-50': !isAvailable, 'cursor-not-allowed opacity-50': !isAvailable,
})} })}
> >
{item.kandang_name}{' '} {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> </label>
</div> </div>
@@ -818,11 +822,33 @@ const TransferToLayingFormModal = () => {
{/* Source Kandang */} {/* Source Kandang */}
<div className='flex flex-col'> <div className='flex flex-col'>
<span className='w-full py-2 text-xs font-semibold'> <span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
Kandang Asal{' '} <span className='text-nowrap'>
<span className='tooltip tooltip-error' data-tip='required'> Kandang Asal{' '}
<span className='text-error'> *</span> <span
className='tooltip tooltip-error'
data-tip='required'
>
<span className='text-error'> *</span>
</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> </span>
{formik.values.flockSourceKandangs.length === 0 && ( {formik.values.flockSourceKandangs.length === 0 && (
@@ -902,23 +928,6 @@ const TransferToLayingFormModal = () => {
<span className='text-error'> *</span> <span className='text-error'> *</span>
</span> </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> </span>
{formik.values.flockDestinationKandangs.length === 0 && ( {formik.values.flockDestinationKandangs.length === 0 && (
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr'; 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 TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal';
import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton'; import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton';
import { import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
TransferToLaying, import { TransferToLayingFilterValues } from '@/components/pages/production/transfer-to-laying/filter/TransferToLayingFilter';
TransferToLayingFilter, import { OptionType } from '@/components/input/SelectInput';
} from '@/types/api/production/transfer-to-laying';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { cn, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -142,6 +141,8 @@ const TransferToLayingsTable = () => {
status: '', status: '',
filter_by: '', filter_by: '',
sort_by: '', sort_by: '',
flockSourceNames: '',
flockDestinationNames: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -154,6 +155,9 @@ const TransferToLayingsTable = () => {
filter_by: 'filter_by', filter_by: 'filter_by',
sort_by: 'sort_by', sort_by: 'sort_by',
}, },
excludeKeysFromUrl: ['flockSourceNames', 'flockDestinationNames'],
persist: true,
storeName: 'transfer-to-laying-table',
}); });
const { const {
@@ -431,12 +435,84 @@ const TransferToLayingsTable = () => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
const filterSubmitHandler = (values: TransferToLayingFilter) => { const STATUS_FILTER_OPTIONS = [
updateFilter('startDate', values.startDate); { value: 'PENDING', label: 'Pengajuan' },
updateFilter('endDate', values.endDate); { value: 'APPROVED', label: 'Disetujui' },
updateFilter('flockSource', values.flockSource.join(',')); { value: 'REJECTED', label: 'Ditolak' },
updateFilter('flockDestination', values.flockDestination.join(',')); ];
updateFilter('status', values.status.join(','));
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 = () => { const filterResetHandler = () => {
@@ -445,6 +521,8 @@ const TransferToLayingsTable = () => {
updateFilter('flockSource', ''); updateFilter('flockSource', '');
updateFilter('flockDestination', ''); updateFilter('flockDestination', '');
updateFilter('status', ''); updateFilter('status', '');
updateFilter('flockSourceNames', '');
updateFilter('flockDestinationNames', '');
}; };
const exportToExcelHandler = async () => { const exportToExcelHandler = async () => {
@@ -558,6 +636,8 @@ const TransferToLayingsTable = () => {
'search', 'search',
'filter_by', 'filter_by',
'sort_by', 'sort_by',
'flockSourceNames',
'flockDestinationNames',
]} ]}
fieldGroups={[['startDate', 'endDate']]} fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal} onClick={filterModal.openModal}
@@ -670,6 +750,7 @@ const TransferToLayingsTable = () => {
<TransferToLayingFilterModal <TransferToLayingFilterModal
ref={filterModal.ref} ref={filterModal.ref}
initialValues={filterModalInitialValues}
onSubmit={filterSubmitHandler} onSubmit={filterSubmitHandler}
onReset={filterResetHandler} onReset={filterResetHandler}
/> />
@@ -3,7 +3,6 @@ import { Uniformity } from '@/types/api/production/uniformity';
type UniformityFormSchemaType = { type UniformityFormSchemaType = {
date: string; date: string;
week: number;
location?: { location?: {
value: number; value: number;
label: string; label: string;
@@ -45,10 +44,6 @@ const FileSchema = Yup.mixed<File>()
export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> = export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> =
Yup.object({ Yup.object({
date: Yup.string().required('Tanggal wajib diisi!'), date: Yup.string().required('Tanggal wajib diisi!'),
week: Yup.number()
.min(1, 'Minggu ke wajib diisi!')
.required('Minggu ke wajib diisi!')
.typeError('Minggu ke wajib diisi!'),
location: Yup.object({ location: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -81,7 +76,6 @@ export type UniformityFormValues = Yup.InferType<typeof UniformityFormSchema>;
export type UniformityFormData = { export type UniformityFormData = {
date: string; date: string;
week: number;
project_flock_kandang_id: number; project_flock_kandang_id: number;
document: File | null; document: File | null;
document_name: string; document_name: string;
@@ -91,8 +85,7 @@ export const getUniformityFormInitialValues = (
initialValues?: Partial<Uniformity> initialValues?: Partial<Uniformity>
): UniformityFormValues => { ): UniformityFormValues => {
return { return {
date: initialValues?.week ? '' : '', date: '',
week: initialValues?.week ?? 0,
location: null, location: null,
location_id: 0, location_id: 0,
project_flock: null, project_flock: null,
@@ -27,7 +27,6 @@ import { LocationApi } from '@/services/api/master-data';
import { import {
ProjectFlockApi, ProjectFlockApi,
ProjectFlockKandangApi, ProjectFlockKandangApi,
RecordingApi,
} from '@/services/api/production'; } from '@/services/api/production';
import { UniformityApi } from '@/services/api/uniformity'; import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -40,7 +39,6 @@ import {
ProjectFlockKandangLookup, ProjectFlockKandangLookup,
ProjectFlock, ProjectFlock,
} from '@/types/api/production/project-flock'; } from '@/types/api/production/project-flock';
import { Recording } from '@/types/api/production/recording';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm'; import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm'; import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -204,23 +202,6 @@ const UniformityForm = ({
? projectFlockKandangLookupData.data ? projectFlockKandangLookupData.data
: undefined; : undefined;
// ===== RECORDINGS DATA (FOR WEEK CALCULATION) =====
const recordingsUrl = useMemo(() => {
if (!projectFlockKandangLookup?.project_flock_kandang_id) return null;
const params = new URLSearchParams({
page: '1',
limit: '100',
project_flock_kandang_id:
projectFlockKandangLookup.project_flock_kandang_id.toString(),
});
return `${RecordingApi.basePath}?${params.toString()}`;
}, [projectFlockKandangLookup?.project_flock_kandang_id]);
const { data: recordingsData } = useSWR(
recordingsUrl,
recordingsUrl ? RecordingApi.getAllFetcher : null
);
// ===== FORM CONFIGURATION ===== // ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<UniformityFormValues>( const formikInitialValues = useMemo<UniformityFormValues>(
() => getUniformityFormInitialValues(initialValues), () => getUniformityFormInitialValues(initialValues),
@@ -246,7 +227,6 @@ const UniformityForm = ({
setUniformityFormData({ setUniformityFormData({
date: values.date, date: values.date,
week: values.week,
project_flock_kandang_id: projectFlockKandangId, project_flock_kandang_id: projectFlockKandangId,
document: values.document as File, document: values.document as File,
document_name: (values.document as File).name, document_name: (values.document as File).name,
@@ -475,59 +455,6 @@ const UniformityForm = ({
generateUniformityTemplate(population, projectFlockKandangLookup); generateUniformityTemplate(population, projectFlockKandangLookup);
}, [projectFlockKandangLookup]); }, [projectFlockKandangLookup]);
// ===== SIDE EFFECTS =====
useEffect(() => {
if (
projectFlockKandangLookup?.chick_in_date &&
projectFlockKandangLookup?.project_flock_kandang_id
) {
const chickInDate = new Date(projectFlockKandangLookup.chick_in_date);
chickInDate.setHours(0, 0, 0, 0);
let initialWeek = 18;
if (
isResponseSuccess(recordingsData) &&
recordingsData.data &&
recordingsData.data.length > 0
) {
const sortedRecordings = [...recordingsData.data].sort(
(a: Recording, b: Recording) =>
new Date(a.record_datetime).getTime() -
new Date(b.record_datetime).getTime()
);
const earliestRecording = sortedRecordings[0];
if (earliestRecording?.project_flock?.production_standart?.week) {
initialWeek =
earliestRecording.project_flock.production_standart.week;
}
}
if (formik.values.date) {
const selectedDate = new Date(formik.values.date);
selectedDate.setHours(0, 0, 0, 0);
const daysDiff = Math.floor(
(selectedDate.getTime() - chickInDate.getTime()) /
(1000 * 60 * 60 * 24)
);
const weeksDiff = Math.floor(daysDiff / 7);
setFieldValue('week', initialWeek + weeksDiff);
} else {
setFieldValue('week', initialWeek);
}
}
}, [
projectFlockKandangLookup?.chick_in_date,
projectFlockKandangLookup?.project_flock_kandang_id,
recordingsData,
formik.values.date,
setFieldValue,
]);
useEffect(() => { useEffect(() => {
const unsub = subscribeValidate(() => { const unsub = subscribeValidate(() => {
setIsValid(true); setIsValid(true);
@@ -597,6 +524,7 @@ const UniformityForm = ({
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.date && Boolean(formik.errors.date)} isError={formik.touched.date && Boolean(formik.errors.date)}
errorMessage={formik.errors.date as string} errorMessage={formik.errors.date as string}
disabled={isNextStep}
/> />
<SelectInput <SelectInput
@@ -615,6 +543,7 @@ const UniformityForm = ({
errorMessage={formik.errors.location_id as string} errorMessage={formik.errors.location_id as string}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isDisabled={isNextStep}
/> />
<SelectInput <SelectInput
@@ -627,7 +556,7 @@ const UniformityForm = ({
onInputChange={setProjectFlockSearchValue} onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks} isLoading={isLoadingProjectFlocks}
onMenuScrollToBottom={loadMoreProjectFlocks} onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id} isDisabled={!formik.values.location_id || isNextStep}
isError={ isError={
formik.touched.project_flock_id && formik.touched.project_flock_id &&
Boolean(formik.errors.project_flock_id) Boolean(formik.errors.project_flock_id)
@@ -644,7 +573,7 @@ const UniformityForm = ({
value={formik.values.kandang} value={formik.values.kandang}
onChange={handleKandangChange} onChange={handleKandangChange}
options={kandangOptions} options={kandangOptions}
isDisabled={!formik.values.project_flock_id} isDisabled={!formik.values.project_flock_id || isNextStep}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -63,7 +63,6 @@ const UniformityResultForm = () => {
try { try {
const payload = { const payload = {
date: uniformityFormData.date, date: uniformityFormData.date,
week: uniformityFormData.week,
project_flock_kandang_id: uniformityFormData.project_flock_kandang_id, project_flock_kandang_id: uniformityFormData.project_flock_kandang_id,
document: uniformityFormData.document, document: uniformityFormData.document,
}; };

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