Compare commits

...

504 Commits

Author SHA1 Message Date
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
179 changed files with 14490 additions and 5270 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 -->
+2 -2
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
@@ -22,4 +22,4 @@ RUN mkdir -p .next/server/app/_next && \
EXPOSE 3000 EXPOSE 3000
CMD ["npx", "serve", ".next/server/app", "-l", "3000"] CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
+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;
+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} />
+197 -151
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';
@@ -39,7 +38,7 @@ 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 { import {
FinanceTableFilterSchema, FinanceTableFilterSchema,
FinanceTableFilterValues, FinanceTableFilterValues,
@@ -176,9 +175,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 +183,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 +203,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 =====
@@ -235,7 +239,7 @@ const FinanceTable = () => {
// ===== 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 +249,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 +343,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 +402,26 @@ const FinanceTable = () => {
); );
}; };
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.orderBy === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('orderBy', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
}
};
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const 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 +482,74 @@ 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 = () => {
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
filterFormik.resetForm();
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -509,10 +568,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 +582,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 +592,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 +602,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 +626,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 +684,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 +745,20 @@ 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,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
</div> </div>
</div> </div>
@@ -741,6 +794,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 +930,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>;
}; };
@@ -82,6 +82,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 =====
@@ -379,6 +380,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,6 +394,7 @@ 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]);
@@ -834,6 +838,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, [formik.values.products, formik.values.deliveries]); }, [formik.values.products, formik.values.deliveries]);
const getAvailableStock = useCallback( const getAvailableStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return (
productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
},
[productWarehouseOptions, type]
);
const getTotalStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
@@ -844,6 +864,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
const hasAvailableQty = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.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 +881,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 +967,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 ( const isGrowingCategory =
recording.executed_at && recording.project_flock?.project_flock_category === 'GROWING';
recording.project_flock?.project_flock_category === 'GROWING' const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
) { if (isGrowingLockedByLaying) {
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 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,63 +31,96 @@ type RecordingGrowingFormSchemaType = {
} | null; } | null;
project_flock_kandang_id: number; project_flock_kandang_id: number;
stocks: { stocks: {
product_warehouse_id: number; product_warehouse_id:
| {
value: number;
label: string;
}
| undefined;
qty: number | string; 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!'), .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!'),
}); });
@@ -243,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: '',
}, },
], ],
@@ -258,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: '',
}, },
], ],
@@ -275,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,
}; };
@@ -0,0 +1,449 @@
'use client';
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { PurchaseFilter } from '@/types/api/purchase/purchase';
import { AreaApi, LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: {
poDate: string;
category: OptionType<number>[];
status: OptionType<string>[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
onSubmit?: (values: PurchaseFilter) => void;
onReset?: () => void;
}
const PurchaseFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: PurchaseFilterModalProps) => {
const closeModalHandler = useCallback(() => {
ref.current?.close();
}, [ref]);
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
// ===== CLEANUP TOAST WHEN MODAL CLOSES =====
useEffect(() => {
const dialogElement = ref.current;
const handleModalClose = () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
dialogElement?.addEventListener('close', handleModalClose);
return () => {
dialogElement?.removeEventListener('close', handleModalClose);
};
}, [ref, dateErrorShown]);
const {
setInputValue: setProductCategoryInputValue,
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategoryOptions,
loadMore: loadMoreProductCategory,
} = useSelect<ProductCategory>(
ProductCategoryApi.basePath,
'id',
'name',
'search'
);
const [selectedAreaId, setSelectedAreaId] = useState(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
const [selectedLocationId, setSelectedLocationId] = useState(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedAreaId || '',
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<{
poDate: string;
category: { label: string; value: number }[];
status: { label: string; value: string }[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
}>({
// enableReinitialize: true,
initialValues: initialValues || {
poDate: '',
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
onSubmit: async (values) => {
const formattedValues = {
...values,
category: values.category.map((item) => String(item.value)),
category_labels: values.category,
status: values.status.map((item) => String(item.value)),
supplier_id: values.supplier?.value,
supplier_label: values.supplier?.label,
area_id: values.area?.value,
area_label: values.area?.label,
location_id: values.location?.value,
location_label: values.location?.label,
project_flock_id: values.project_flock?.value,
project_flock_label: values.project_flock?.label,
project_flock_kandang_id: values.project_flock_kandang?.value,
project_flock_kandang_label: values.project_flock_kandang?.label,
};
onSubmit?.(formattedValues);
closeModalHandler();
},
onReset: () => {
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
},
});
const { resetForm, submitForm } = formik;
useEffect(() => {
setSelectedAreaId(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.area, initialValues?.location]);
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
const productCategoryChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('category', val);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val);
};
const formikResetHandler = useCallback(() => {
resetForm({
values: {
poDate: '',
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const formikSubmitHandler = useCallback(async () => {
await submitForm();
}, [submitForm]);
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
<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 Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
closeModalHandler();
}}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<DateInput
label='PO Date'
name='poDate'
placeholder='Pilih Tanggal'
value={formik.values.poDate}
onChange={formik.handleChange}
isNestedModal
/>
<SelectInputCheckbox
label='Kategori'
placeholder='Pilih Kategori'
value={formik.values.category}
onChange={productCategoryChangeHandler}
options={productCategoryOptions}
isLoading={isLoadingProductCategoryOptions}
onInputChange={setProductCategoryInputValue}
onMenuScrollToBottom={loadMoreProductCategory}
/>
<SelectInputCheckbox
label='Status'
placeholder='Status'
value={formik.values.status}
onChange={statusChangeHandler}
options={PURCHASE_ORDER_APPROVAL_LINE.map((item) => ({
label: item.step_name,
value: item.step_name,
}))}
/>
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
value={formik.values.supplier}
onChange={(val) =>
formik.setFieldValue(
'supplier',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isClearable
/>
<SelectInput
label='Area'
placeholder='Pilih Area'
value={formik.values.area}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('area', nextValue);
formik.setFieldValue('location', null);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedAreaId(
nextValue?.value ? String(nextValue.value) : ''
);
setSelectedLocationId('');
}}
options={areaOptions}
isLoading={isLoadingAreaOptions}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
value={formik.values.location}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('location', nextValue);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(
nextValue?.value ? String(nextValue.value) : ''
);
}}
options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!formik.values.area}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
value={formik.values.project_flock}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('project_flock', nextValue);
formik.setFieldValue('project_flock_kandang', null);
}}
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!formik.values.location}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={projectFlockKandangOptions}
isClearable
isDisabled={!formik.values.project_flock}
/>
</div>
</div>
{/* Modal Footer */}
<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='button'
onClick={formikSubmitHandler}
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 PurchaseFilterModal;

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