Compare commits

...

309 Commits

Author SHA1 Message Date
ValdiANS 4151829cb8 fix: disabled deliver item button if is submitting and set the is loading prop 2026-06-03 14:24:39 +07:00
ValdiANS f167916a21 fix: replace throw error with axios error handling in SalesOrderService and MarketingExportService
All catch blocks in singleApproval, bulkApprovals (both classes), and
delivery now return error.response?.data for axios errors and undefined
otherwise, consistent with the BaseApiService pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:20:43 +07:00
ValdiANS 97acc17ca5 feat: add inline edit for chick-in date in chickin logs
- Add updateChickinDate method to ChickinService (PATCH /production/chickins/chick-in-date)
- Add pencil icon button next to each chickin date in ChickLogsView
- Clicking the icon toggles an inline DateInput with Simpan/Batal buttons
- Save button is disabled and shows loading state while request is in flight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:05:06 +07:00
Giovanni Gabriel Septriadi a1cb401a1c Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!498
2026-05-29 16:32:00 +00:00
Rivaldi A N S 3f1c1b62e2 Merge branch 'feat/purchase-po-pr-copy-paste' into 'development'
[FEAT/FE] Purchase PO & PR Number Copy Button

See merge request mbugroup/lti-web-client!497
2026-05-29 10:13:13 +00:00
ValdiANS 5c9fa12347 feat: add copy button for PR and PO number 2026-05-29 17:11:42 +07:00
Giovanni Gabriel Septriadi 2ea6e1a5a5 Merge branch 'development' into 'production'
feat: add server-side Excel export to PurchasesPerSupplierTab

See merge request mbugroup/lti-web-client!496
2026-05-25 08:16:37 +00:00
Rivaldi A N S aa935b8851 Merge branch 'feat/export-balance-monitoring' into 'development'
[FEAT/FE] Export Balance Monitoring

See merge request mbugroup/lti-web-client!495
2026-05-25 07:25:07 +00:00
ValdiANS b8419a3f69 feat: add Excel export to BalanceMonitoringTab
Add exportBalanceMonitoringToExcel to FinanceApiService (server-side
blob download hitting reports/balance-monitoring?export=excel). Wire it
into BalanceMonitoringTab via a Dropdown export button in the tab
actions. Wrap the handler in useCallback to prevent an infinite
setTabActions loop caused by a new function reference on every render.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:14:40 +07:00
Rivaldi A N S eaf70ead70 Merge branch 'feat/export-report-purchases-per-supplier' into 'development'
[FEAT/FE] Export Report Purchases Per Supplier

See merge request mbugroup/lti-web-client!494
2026-05-25 04:33:09 +00:00
ValdiANS 7e6f250864 feat: add server-side Excel export to PurchasesPerSupplierTab
Add exportToExcelSupplierPerSheet and exportToExcelGeneral methods to
LogisticApiService, hitting the existing purchase-supplier endpoint with
export=excel / export=excel-all query params and downloading the server
blob response. Replace the client-side Excel generation in
PurchasesPerSupplierTab with calls to these two service methods, and
split the single Export to Excel button into Export to Excel - Supplier
Per Sheet and Export to Excel - General.
2026-05-25 11:28:41 +07:00
Giovanni Gabriel Septriadi d7a98a77ea Merge branch 'development' into 'production'
refactor: optimize BalanceMonitoringTab with useTableFilter persistence pattern

See merge request mbugroup/lti-web-client!493
2026-05-25 04:04:15 +00:00
Rivaldi A N S 13eb0594a8 Merge branch 'hotfix/transfer-to-laying' into 'development'
[HOTFIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!492
2026-05-25 03:39:13 +00:00
ValdiANS 22b3350e4a fix: set flock source and destination raw data accordingly 2026-05-25 10:37:41 +07:00
Rivaldi A N S 55424b272f Merge branch 'fix/depreciation-report' into 'development'
[FIX/FE] Depreciation Report

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

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-web-client!485
2026-05-19 07:47:51 +00:00
ValdiANS a99a399f09 fix: show kandang label even if its not loaded yet in kandang options 2026-05-19 14:44:12 +07:00
Giovanni Gabriel Septriadi a314a62f1f Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!484
2026-05-19 06:48:45 +00: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 2bf5f36a77 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!474
2026-05-12 09:31:27 +00: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
Giovanni Gabriel Septriadi 989e30fbed Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!468
2026-05-11 08:32:23 +00: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
Adnan Zahir 40139cd636 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!456
2026-05-05 14:12:28 +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
Adnan Zahir 8c03f10043 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!454
2026-05-02 13:43:29 +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
Adnan Zahir 89a6e51b48 Merge branch 'development' into 'production'
Revert "fixing devops"

See merge request mbugroup/lti-web-client!449
2026-04-30 09:54:39 +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 f6727dc4dc Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!446
2026-04-29 12:53:07 +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 1284b22345 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!441
2026-04-28 13:43:32 +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 f73ea182ae Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!435
2026-04-26 00:13:15 +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 047266b6d8 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!432
2026-04-25 14:46:53 +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
Adnan Zahir 6b95edfb72 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!427
2026-04-23 12:38:36 +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 4b62b02a13 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!420
2026-04-22 13:12:50 +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
Adnan Zahir 12a50c6100 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!410
2026-04-20 08:24:51 +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
Adnan Zahir 09537d84d0 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!407
2026-04-18 09:41:09 +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 1aa2ca9b31 Merge branch 'development' into 'production'
refactor(FE-add-param): Update MarketingFilter to refine API calls and

See merge request mbugroup/lti-web-client!400
2026-04-14 13:20:27 +07:00
Adnan Zahir 244d800874 codex/fix: uniformity week calculation 2026-04-14 13:10:53 +07:00
Adnan Zahir c87107b4ee Merge branch 'development' into 'production'
refactor(FE-load-more-option): Add infinite scroll to location and

See merge request mbugroup/lti-web-client!396
2026-04-13 14:08:57 +07:00
Adnan Zahir 55b13988bf Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!393
2026-04-13 11:17:24 +07:00
Adnan Zahir 19033278b3 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!388
2026-04-11 14:13:02 +07:00
Adnan Zahir 4a6ac8a57d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!382
2026-04-09 15:36:40 +07:00
Adnan Zahir 2b9847e1a9 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!378
2026-04-08 13:39:17 +07:00
Adnan Zahir 167769a711 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!375
2026-04-07 22:56:27 +07:00
Adnan Zahir 417dbba458 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!373
2026-04-07 16:53:36 +07:00
145 changed files with 11729 additions and 4172 deletions
+3
View File
@@ -48,3 +48,6 @@ next-env.d.ts
# rtk
rtk.exe
# local specs
/local-specs
+21 -2
View File
@@ -30,6 +30,10 @@ default:
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
- echo "NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV"
- echo "NEXT_PUBLIC_HELPDESK_URL=$NEXT_PUBLIC_HELPDESK_URL"
- echo "NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL=$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
- echo "NEXT_PUBLIC_S3_PUBLIC_BASE_URL=$NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
- echo "Building Next.js static export..."
- npx next build
- |
@@ -41,7 +45,11 @@ default:
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_APP_ENV": "$NEXT_PUBLIC_APP_ENV",
"NEXT_PUBLIC_HELPDESK_URL": "$NEXT_PUBLIC_HELPDESK_URL",
"NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL": "$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
"NEXT_PUBLIC_S3_PUBLIC_BASE_URL": "NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
}
EOF
artifacts:
@@ -142,6 +150,10 @@ build:dev:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'development'
NEXT_PUBLIC_HELPDESK_URL: 'https://dev-helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/'
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com'
deploy:dev:
<<: *deploy_template
@@ -170,6 +182,9 @@ build:staging:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'staging'
NEXT_PUBLIC_HELPDESK_URL: 'https://stg-helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://stg-dashboard-ho.mbugroup.id/'
deploy:staging:
<<: *deploy_template
@@ -185,7 +200,7 @@ deploy:staging:
url: https://stg-lti-erp.mbugroup.id
# ==========================================================
# ====== STAGING (Branch production) ======
# ====== (Branch production) ======
# ==========================================================
build:production:
<<: *build_template
@@ -198,6 +213,10 @@ build:production:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'production'
NEXT_PUBLIC_HELPDESK_URL: 'https://helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dashboard-ho.mbugroup.id/'
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/'
deploy:production:
<<: *deploy_template
+1
View File
@@ -1,3 +1,4 @@
npm run format
npm run lint
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
@@ -15,8 +15,8 @@ const ExpenseDetailPage = () => {
const expenseId = searchParams.get('expenseId');
const { data: expense, isLoading: isLoadingExpense } = useSWR(
expenseId,
(id: number) => ExpenseApi.getSingle(id)
['expense-detail', expenseId],
([_, id]) => ExpenseApi.getSingle(Number(id))
);
if (!expenseId) {
@@ -0,0 +1,11 @@
import { SystemConfigContent } from '@/figma-make/components/pages/master-data/system-config/SystemConfigContent';
const SystemConfigPage = () => {
return (
<section className='w-full'>
<SystemConfigContent />
</section>
);
};
export default SystemConfigPage;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+1 -1
View File
@@ -226,7 +226,7 @@ const Pagination = ({
const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'>
Page {currentPage} of {totalPages}
Total Item: {totalItems} | Page {currentPage} of {totalPages}
</span>
);
+1
View File
@@ -173,6 +173,7 @@ const Table = <TData extends object>({
const tableOptions: TableOptions<TData> = {
columns,
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
defaultColumn: { sortDescFirst: false },
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
+14 -11
View File
@@ -6,6 +6,7 @@ export interface TabItem {
label: ReactNode;
content?: ReactNode;
disabled?: boolean;
hide?: boolean;
}
export interface TabsProps
@@ -122,17 +123,19 @@ const Tabs = ({
>
<div className={getSideContentClasses()}>
<div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled }) => (
<button
key={id}
role='tab'
className={getTabClasses(id === activeTabId, disabled)}
onClick={() => !disabled && handleTabChange(id)}
disabled={disabled}
>
{label}
</button>
))}
{tabs.map(({ id, label, disabled, hide }) =>
hide ? null : (
<button
key={id}
role='tab'
className={getTabClasses(id === activeTabId, disabled)}
onClick={() => !disabled && handleTabChange(id)}
disabled={disabled}
>
{label}
</button>
)
)}
</div>
{sideContent && sideContent}
</div>
+1 -1
View File
@@ -523,7 +523,7 @@ const useSelect = <T,>(
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey]: inputValue ?? '',
[searchKey ? searchKey : 'search']: inputValue ?? '',
[pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString();
@@ -69,6 +69,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
secondaryButton={
secondaryButton
? {
...secondaryButton,
text: secondaryButton?.text ?? 'Tidak',
onClick: (e) => {
if (secondaryButton && secondaryButton?.onClick) {
@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
@@ -15,6 +16,7 @@ interface ExpenseDetailProps {
}
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter();
const [activeTab, setActiveTab] = useState<string>('request');
const expenseDetailTabs = useMemo(() => {
@@ -46,8 +48,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
<section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
variant='link'
onClick={router.back}
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
@@ -1,5 +1,8 @@
'use client';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Icon } from '@iconify/react';
@@ -16,6 +19,7 @@ import {
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
interface ExpenseRealizationContentProps {
@@ -25,6 +29,8 @@ interface ExpenseRealizationContentProps {
const ExpenseRealizationContent = ({
initialValues,
}: ExpenseRealizationContentProps) => {
const searchParams = useSearchParams();
const formik = useFormik<UploadRequestDocumentsFormValues>({
initialValues: {
documents: [],
@@ -74,7 +80,11 @@ const ExpenseRealizationContent = ({
<Button
type='button'
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'
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSWRConfig } from 'swr';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -19,6 +20,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper';
@@ -26,11 +28,15 @@ import {
UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
import { BaseApiResponse } from '@/types/api/api-general';
import {
buildExpenseActionHref,
getExpenseListReturnTo,
} from '@/lib/expense-list-navigation';
interface ExpenseRequestContentProps {
initialValues?: Expense;
@@ -40,6 +46,13 @@ const ExpenseRequestContent = ({
initialValues,
}: ExpenseRequestContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const { mutate } = useSWRConfig();
const refreshExpense = () => {
mutate((key) => Array.isArray(key) && key[0] === 'expense-detail');
};
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
useApprovalSteps({
@@ -89,17 +102,24 @@ const ExpenseRequestContent = ({
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 4;
const isExpensePaidOff = initialValues?.is_paid;
const showPaidOffButton =
!isExpensePaidOff && (initialValues?.latest_approval.step_number ?? 0) >= 4;
// Modal hooks
const deleteModal = useModal();
const completeModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const paidOffModal = useModal();
// Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const formik = useFormik<UploadRequestDocumentsFormValues>({
@@ -140,7 +160,31 @@ const ExpenseRequestContent = ({
rejectModal.openModal();
};
const paidOffClickHandler = () => {
paidOffModal.openModal();
};
// Modal confirm click handler
const confirmationModalPaidOffClickHandler = async () => {
setIsPaidOffLoading(true);
const paidOffResponse = await ExpenseApi.setExpensePaidOff(
initialValues?.id as number
);
if (isResponseSuccess(paidOffResponse)) {
toast.success('Berhasil menandai biaya operasional sebagai lunas!');
refreshExpense();
} else {
toast.error(
'Gagal menandai biaya operasional sebagai lunas!: ' +
paidOffResponse?.message
);
}
paidOffModal.closeModal();
setIsPaidOffLoading(false);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -148,7 +192,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(deleteResponse)) {
toast.success('Berhasil menghapus data biaya operasional!');
router.push('/expense');
router.push(returnTo);
} else {
toast.error('Gagal menghapus data biaya operasional!');
}
@@ -164,7 +208,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(completeRes)) {
toast.success(completeRes.message);
router.push('/expense');
router.push(returnTo);
} else {
toast.error(completeRes?.message as string);
}
@@ -204,7 +248,7 @@ const ExpenseRequestContent = ({
toast.success(approveResponse?.message);
setApprovalNotes('');
router.push('/expense');
router.push(returnTo);
} else {
approveModal.closeModal();
@@ -239,7 +283,7 @@ const ExpenseRequestContent = ({
toast.success(rejectResponse.message);
setApprovalNotes('');
router.push('/expense');
router.push(returnTo);
} else {
rejectModal.closeModal();
@@ -365,7 +409,11 @@ const ExpenseRequestContent = ({
<Button
variant='outline'
color='info'
href={`/expense/realization/?expenseId=${initialValues?.id}`}
href={buildExpenseActionHref(
'/expense/realization/',
initialValues?.id as number,
searchParams
)}
className='w-full sm:w-fit'
>
<Icon
@@ -378,13 +426,35 @@ const ExpenseRequestContent = ({
</RequirePermission>
)}
{showPaidOffButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
variant='outline'
color='success'
onClick={paidOffClickHandler}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:check-circle-outline'
width={24}
height={24}
/>
Tandai Lunas
</Button>
</RequirePermission>
)}
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<Button
type='button'
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'
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -519,6 +589,19 @@ const ExpenseRequestContent = ({
/>
</td>
</tr>
<tr>
<th>Status Lunas</th>
<th>:</th>
<td>
<StatusBadge
color={initialValues?.is_paid ? 'primary' : 'warning'}
text={initialValues?.is_paid ? 'Lunas' : 'Belum Lunas'}
className={{
badge: 'w-fit whitespace-nowrap',
}}
/>
</td>
</tr>
<tr>
<th>Dokumen Pengajuan</th>
<th>:</th>
@@ -534,21 +617,15 @@ const ExpenseRequestContent = ({
<ul className='list-disc'>
{initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => {
const path = requestDocument.path.startsWith(
'/'
)
? requestDocument.path.slice(1)
: requestDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return (
<li key={requestDocumentIdx}>
<Link
href={documentUrl}
href={requestDocument.path}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{requestDocument.path}{' '}
{requestDocument.name}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
@@ -744,6 +821,21 @@ const ExpenseRequestContent = ({
onClick: confirmationModalRejectClickHandler,
}}
/>
<ConfirmationModal
ref={paidOffModal.ref}
type='success'
text='Apakah anda yakin ingin menandai biaya operasional ini sebagai lunas?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isPaidOffLoading,
onClick: confirmationModalPaidOffClickHandler,
}}
/>
</>
);
};
File diff suppressed because it is too large Load Diff
@@ -3,26 +3,60 @@ import * as yup from 'yup';
export type ExpensesFilterType = {
transaction_date: string | null;
realization_date: string | null;
location_id: string | null;
vendor_id: string | null;
location: { value: number; label: string } | null;
vendor: { value: number; label: string } | null;
category: { value: string; label: string } | null;
approval_status: { value: string; label: string } | null;
realization_status: { value: string; label: string } | null;
project_flock: { value: number; label: string } | null;
project_flock_kandang: { value: number; label: string } | null;
};
export const ExpensesFilterSchema = yup.object({
transaction_date: yup.string().nullable(),
realization_date: yup
.string()
.nullable()
.test(
'is-greater-or-equal-transaction',
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
function (value) {
const { transaction_date } = this.parent;
if (!transaction_date || !value) return true;
return new Date(value) >= new Date(transaction_date);
}
),
location_id: yup.string().nullable(),
vendor_id: yup.string().nullable(),
realization_date: yup.string().nullable(),
location: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
vendor: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
category: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
approval_status: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
realization_status: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
project_flock: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
project_flock_kandang: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
});
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -1,6 +1,6 @@
'use client';
import { RefObject } from 'react';
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
@@ -11,8 +11,11 @@ import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
import {
ExpensesFilterSchema,
ExpensesFilterValues,
@@ -31,64 +34,143 @@ const ExpensesFilterModal = ({
onSubmit,
onReset,
}: ExpensesFilterModalProps) => {
const [selectedLocationId, setSelectedLocationId] = useState<string>(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const closeModalHandler = () => {
ref.current?.close();
};
const categoryOptions = [
{ value: 'BOP', label: 'BOP' },
{ value: 'NON-BOP', label: 'NON-BOP' },
];
const approvalStatusOptions = [
{ value: 'HEAD_AREA', label: 'Approval Head Area' },
{ value: 'UNIT_VICE_PRESIDENT', label: 'Approval Unit Vice President' },
{ value: 'FINANCE', label: 'Approval Finance' },
{ value: 'REALISASI', label: 'Realisasi' },
{ value: 'SELESAI', label: 'Selesai' },
{ value: 'DITOLAK', label: 'Ditolak' },
];
const realizationStatusOptions = [
{ value: 'NOT_REALIZED', label: 'Belum Realisasi' },
{ value: 'REALIZED', label: 'Sudah Realisasi' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreVendors,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const {
setInputValue: setProjectFlockInputValue,
rawData: projectFlocksRawData,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<ExpensesFilterValues>({
enableReinitialize: true,
initialValues: initialValues || {
transaction_date: null,
realization_date: null,
location_id: null,
vendor_id: null,
location: null,
vendor: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
},
validationSchema: ExpensesFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
const locationValue = formik.values.location_id
? locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
: null;
useEffect(() => {
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.location]);
const vendorValue = formik.values.vendor_id
? vendorOptions.find(
(opt) => String(opt.value) === formik.values.vendor_id
) || null
: null;
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({
values: {
transaction_date: null,
realization_date: null,
location: null,
vendor: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('location_id', locationId);
const value = val as OptionType | null;
formik.setFieldValue('location', value);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(value?.value ? String(value.value) : '');
};
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
const vendorId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('vendor_id', vendorId);
formik.setFieldValue('vendor', val as OptionType | null);
};
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
return (
<Modal
ref={ref}
@@ -98,7 +180,7 @@ const ExpensesFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -121,49 +203,41 @@ const ExpensesFilterModal = ({
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<span className='py-2 text-xs font-semibold'>Tanggal</span>
<div className='flex flex-row items-center gap-1.5'>
<DateInput
name='transaction_date'
placeholder='Tanggal Transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='realization_date'
placeholder='Tanggal Realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
</div>
{formik.touched.realization_date &&
formik.errors.realization_date && (
<span className='text-xs text-error'>
{formik.errors.realization_date}
</span>
)}
</div>
<DateInput
name='transaction_date'
label='Tanggal Transaksi'
placeholder='Tanggal Transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<DateInput
name='realization_date'
label='Tanggal Realisasi'
placeholder='Tanggal Realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationValue}
value={formik.values.location}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
@@ -173,14 +247,87 @@ const ExpensesFilterModal = ({
label='Vendor'
placeholder='Pilih Vendor'
options={vendorOptions}
value={vendorValue}
value={formik.values.vendor}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreVendors}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={formik.values.category}
onChange={(val) =>
formik.setFieldValue('category', val as OptionType | null)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status BOP'
placeholder='Pilih Status BOP'
options={approvalStatusOptions}
value={formik.values.approval_status}
onChange={(val) =>
formik.setFieldValue('approval_status', val as OptionType | null)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status Pencairan'
placeholder='Pilih Status Pencairan'
options={realizationStatusOptions}
value={formik.values.realization_status}
onChange={(val) =>
formik.setFieldValue(
'realization_status',
val as OptionType | null
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={formik.values.project_flock}
onChange={(val) => {
formik.setFieldValue('project_flock', val as OptionType | null);
formik.setFieldValue('project_flock_kandang', null);
}}
onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlockOptions}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
val as OptionType | null
)
}
isClearable
isDisabled={!formik.values.project_flock}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useFormik } from 'formik';
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 { Supplier } from '@/types/api/master-data/supplier';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
@@ -48,6 +49,8 @@ const ExpenseRealizationForm = ({
initialValues,
}: ExpenseRealizationFormProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
@@ -64,9 +67,9 @@ const ExpenseRealizationForm = ({
}
toast.success(createExpenseRes?.message as string);
router.push('/expense');
router.push(returnTo);
},
[router]
[initialValues?.id, returnTo, router]
);
const updateExpenseHandler = useCallback(
@@ -83,9 +86,9 @@ const ExpenseRealizationForm = ({
toast.success(updateExpenseRes?.message as string);
router.refresh();
router.push('/expense');
router.push(returnTo);
},
[router]
[returnTo, router]
);
const formik = useFormik<ExpenseRealizationFormValues>({
@@ -258,7 +261,7 @@ const ExpenseRealizationForm = ({
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
href={returnTo}
variant='link'
className='w-fit p-0 text-primary'
>
+256 -149
View File
@@ -1,13 +1,12 @@
'use client';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import React, { useEffect, useMemo, useState } from 'react';
import {
CellContext,
ColumnDef,
SortingState,
Updater,
} from '@tanstack/react-table';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
@@ -30,7 +29,7 @@ import {
FINANCE_TRANSACTION_TYPE_OPTIONS,
} from '@/config/constant';
import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank';
import Modal, { useModal } from '@/components/Modal';
@@ -39,7 +38,8 @@ import PopoverContent from '@/components/popover/PopoverContent';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import RequirePermission from '@/components/helper/RequirePermission';
import { useUiStore } from '@/stores/ui/ui.store';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/dropdown/Dropdown';
import {
FinanceTableFilterSchema,
FinanceTableFilterValues,
@@ -176,9 +176,6 @@ const RowOptionsMenu = ({
};
const FinanceTable = () => {
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
const previousPathRef = useRef<string | null>(null);
const {
state: tableFilterState,
updateFilter,
@@ -187,14 +184,18 @@ const FinanceTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
transactionTypes: '',
bankIds: '',
customerIds: '',
supplierIds: '',
sortBy: '',
sort_by: '',
orderBy: '',
startDate: '',
endDate: '',
bankNames: '',
customerNames: '',
supplierNames: '',
},
paramMap: {
page: 'page',
@@ -203,10 +204,14 @@ const FinanceTable = () => {
bankIds: 'bank_ids',
customerIds: 'customer_ids',
supplierIds: 'supplier_ids',
sortBy: 'sort_date',
sort_by: 'sort_by',
orderBy: 'sort_order',
startDate: 'start_date',
endDate: 'end_date',
},
excludeKeysFromUrl: ['bankNames', 'customerNames', 'supplierNames'],
persist: true,
storeName: 'finance-table',
});
// ===== FILTER MODAL STATE =====
@@ -229,13 +234,14 @@ const FinanceTable = () => {
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isExportLoading, setIsExportLoading] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== Formik for Filter =====
const filterFormik = useFormik<FinanceTableFilterValues>({
initialValues: {
search: searchValue,
search: tableFilterState.search || '',
transaction_types: '',
bank_ids: '',
customer_ids: '',
@@ -245,29 +251,48 @@ const FinanceTable = () => {
end_date: '',
},
validationSchema: FinanceTableFilterSchema,
enableReinitialize: true,
onSubmit: (values) => {
updateFilter('search', values.search);
setSearchValue(values.search);
updateFilter('transactionTypes', values.transaction_types);
updateFilter('bankIds', values.bank_ids);
updateFilter('customerIds', values.customer_ids);
updateFilter('supplierIds', values.supplier_ids);
updateFilter('sortBy', values.sort_by);
updateFilter('startDate', values.start_date);
updateFilter('endDate', values.end_date);
onSubmit: (values, { setSubmitting }) => {
updateFilter('search', values.search, true);
updateFilter('transactionTypes', values.transaction_types, true);
updateFilter('bankIds', values.bank_ids, true);
updateFilter('customerIds', values.customer_ids, true);
updateFilter('supplierIds', values.supplier_ids, true);
updateFilter('sort_by', values.sort_by, true);
updateFilter('startDate', values.start_date, true);
updateFilter('endDate', values.end_date, true);
// Save display names for restoration on modal reopen
const toNames = (val: OptionType | OptionType[] | null) =>
val
? (Array.isArray(val) ? val : [val])
.map((o) => String(o.label))
.join(',')
: '';
updateFilter('bankNames', toNames(selectedBank), true);
updateFilter('customerNames', toNames(selectedCustomerId), true);
updateFilter('supplierNames', toNames(selectedSupplierId), true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
updateFilter('search', '', true);
updateFilter('transactionTypes', '', true);
updateFilter('bankIds', '', true);
updateFilter('customerIds', '', true);
updateFilter('supplierIds', '', true);
updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
updateFilter('startDate', '', true);
updateFilter('endDate', '', true);
updateFilter('bankNames', '', true);
updateFilter('customerNames', '', true);
updateFilter('supplierNames', '', true);
filterModal.closeModal();
},
});
@@ -320,40 +345,10 @@ const FinanceTable = () => {
});
}, [bankOptions, bankRawData]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.transactionTypes) count += 1;
if (tableFilterState.bankIds) count += 1;
if (tableFilterState.customerIds) count += 1;
if (tableFilterState.supplierIds) count += 1;
if (tableFilterState.sortBy) count += 1;
if (tableFilterState.startDate) count += 1;
if (tableFilterState.endDate) count += 1;
return count;
}, [
tableFilterState.transactionTypes,
tableFilterState.bankIds,
tableFilterState.customerIds,
tableFilterState.supplierIds,
tableFilterState.sortBy,
tableFilterState.startDate,
tableFilterState.endDate,
]);
const hasFilters = activeFiltersCount > 0;
// ===== Handler =====
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value, true);
};
const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null
@@ -409,6 +404,26 @@ const FinanceTable = () => {
);
};
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.orderBy === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('orderBy', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
}
};
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const endDate = filterFormik.values.end_date;
@@ -469,28 +484,88 @@ const FinanceTable = () => {
};
const handleFilterModalOpen = () => {
// Restore transaction types from stored comma-separated IDs
const txIds = tableFilterState.transactionTypes
? tableFilterState.transactionTypes.split(',')
: [];
const restoredTxTypes = FINANCE_TRANSACTION_TYPE_OPTIONS.filter((opt) =>
txIds.includes(String(opt.value))
);
setSelectedTransactionType(restoredTxTypes.length ? restoredTxTypes : null);
// Restore banks from stored IDs and names
const bankIdList = tableFilterState.bankIds
? tableFilterState.bankIds.split(',')
: [];
const bankNameList = tableFilterState.bankNames
? tableFilterState.bankNames.split(',')
: [];
const restoredBanks = bankIdList.map((id, i) => ({
value: id,
label: bankNameList[i] || id,
}));
setSelectedBank(restoredBanks.length ? restoredBanks : null);
// Restore customers from stored IDs and names
const customerIdList = tableFilterState.customerIds
? tableFilterState.customerIds.split(',')
: [];
const customerNameList = tableFilterState.customerNames
? tableFilterState.customerNames.split(',')
: [];
const restoredCustomers = customerIdList.map((id, i) => ({
value: id,
label: customerNameList[i] || id,
}));
setSelectedCustomerId(restoredCustomers.length ? restoredCustomers : null);
// Restore suppliers from stored IDs and names
const supplierIdList = tableFilterState.supplierIds
? tableFilterState.supplierIds.split(',')
: [];
const supplierNameList = tableFilterState.supplierNames
? tableFilterState.supplierNames.split(',')
: [];
const restoredSuppliers = supplierIdList.map((id, i) => ({
value: id,
label: supplierNameList[i] || id,
}));
setSelectedSupplierId(restoredSuppliers.length ? restoredSuppliers : null);
// Restore sort by
const restoredSortBy =
sortByOptions.find(
(opt) => String(opt.value) === tableFilterState.sort_by
) || null;
setSelectedSortBy(restoredSortBy);
// Restore formik values
filterFormik.setValues({
search: tableFilterState.search || '',
transaction_types: tableFilterState.transactionTypes || '',
bank_ids: tableFilterState.bankIds || '',
customer_ids: tableFilterState.customerIds || '',
supplier_ids: tableFilterState.supplierIds || '',
sort_by: tableFilterState.sort_by || '',
start_date: tableFilterState.startDate || '',
end_date: tableFilterState.endDate || '',
});
filterModal.openModal();
filterFormik.validateForm();
};
const resetFilterHandler = () => {
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
filterFormik.resetForm();
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
const exportToExcel = async () => {
setIsExportLoading(true);
try {
await FinanceApi.exportToExcel(getTableFilterQueryString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data finance.')
);
} finally {
setIsExportLoading(false);
}
};
const confirmationModalDeleteClickHandler = async () => {
@@ -509,10 +584,12 @@ const FinanceTable = () => {
{
header: 'ID',
accessorKey: 'payment_code',
enableSorting: true,
},
{
header: 'References Number',
accessorKey: 'reference_number',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.reference_number;
return <span>{value ?? '-'}</span>;
@@ -521,6 +598,7 @@ const FinanceTable = () => {
{
header: 'Jenis Transaksi',
accessorKey: 'transaction_type',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.transaction_type
.split('_')
@@ -530,7 +608,8 @@ const FinanceTable = () => {
},
{
header: 'Pihak',
accessorFn: (finance: Finance) => finance.party?.name,
accessorKey: 'customer_name',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party?.id) {
return <span>{props.row.original.party?.name}</span>;
@@ -539,13 +618,23 @@ const FinanceTable = () => {
},
},
{
header: 'Tanggal',
accessorFn: (finance: Finance) =>
formatDate(finance.payment_date, 'DD MMM YYYY'),
header: 'Tanggal Pembayaran',
accessorKey: 'payment_date',
enableSorting: true,
cell: (props) =>
formatDate(props.row.original.payment_date, 'DD MMM YYYY'),
},
{
header: 'Tanggal Dibuat',
accessorKey: 'created_at',
enableSorting: true,
cell: (props) =>
formatDate(props.row.original.created_at, 'DD MMM YYYY'),
},
{
header: 'Metode Pembayaran',
accessorKey: 'payment_method',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.payment_method.split('_').join(' ');
return <span>{formatTitleCase(value)}</span>;
@@ -553,20 +642,26 @@ const FinanceTable = () => {
},
{
header: 'Bank',
accessorFn: (finance: Finance) =>
finance.bank
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`
accessorKey: 'bank',
enableSorting: true,
cell: (props) =>
props.row.original.bank
? `${props.row.original.bank?.alias} - ${props.row.original.bank?.account_number} - ${props.row.original.bank?.owner}`
: '-',
},
{
header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) =>
formatCurrency(Math.abs(finance.expense_amount)),
accessorKey: 'expense_amount',
enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.expense_amount)),
},
{
header: 'Pemasukan (Rp)',
accessorFn: (finance: Finance) =>
formatCurrency(Math.abs(finance.income_amount)),
accessorKey: 'income_amount',
enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.income_amount)),
},
{
header: 'Aksi',
@@ -605,27 +700,6 @@ const FinanceTable = () => {
};
}, [dateErrorShown]);
useEffect(() => {
previousPathRef.current = window.location.pathname;
return () => {
const currentPath = window.location.pathname;
const isCurrentPathFinance = currentPath.includes('/finance');
const isPreviousPathFinance =
previousPathRef.current?.includes('/finance');
if (isPreviousPathFinance && !isCurrentPathFinance) {
resetSearchValue();
}
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [resetSearchValue, dateErrorShown]);
return (
<>
<div className='w-full'>
@@ -687,25 +761,65 @@ const FinanceTable = () => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'orderBy',
'bankNames',
'customerNames',
'supplierNames',
]}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
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>Ekspor</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
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>
<Button
variant='ghost'
color='none'
onClick={exportToExcel}
isLoading={isExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
</Dropdown>
</div>
</div>
@@ -741,6 +855,9 @@ const FinanceTable = () => {
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
@@ -874,19 +991,9 @@ const FinanceTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
filterFormik.resetForm();
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
resetFilterHandler();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -7,8 +7,7 @@ import {
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useFormik } from 'formik';
@@ -25,7 +24,6 @@ import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -100,25 +98,31 @@ const RowOptionsMenu = ({
};
const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
productCategorySort: string;
productSort: string;
warehouseSort: string;
stockSort: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
transactionTypeFilter?: OptionType<string>;
}>({
initial: {
search: '',
productCategorySort: '',
productSort: '',
warehouseSort: '',
stockSort: '',
productFilter: '',
warehouseFilter: '',
transactionTypeFilter: '',
productFilter: undefined,
warehouseFilter: undefined,
transactionTypeFilter: undefined,
},
paramMap: {
page: 'page',
@@ -131,6 +135,8 @@ const InventoryAdjustmentTable = () => {
warehouseFilter: 'warehouse_id',
transactionTypeFilter: 'transaction_type',
},
persist: true,
storeName: 'inventory-adjustment-table',
});
// ===== FILTER MODAL STATE =====
@@ -139,22 +145,27 @@ const InventoryAdjustmentTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<AdjustmentFilterType>({
initialValues: {
product_id: null,
warehouse: null,
transaction_type: null,
product: tableFilterState.productFilter,
warehouse: tableFilterState.warehouseFilter,
transaction_type: tableFilterState.transactionTypeFilter,
},
validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', String(values.warehouse?.value) || '');
updateFilter('transactionTypeFilter', values.transaction_type || '');
updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse || undefined, true);
updateFilter(
'transactionTypeFilter',
values.transaction_type || undefined,
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
updateFilter('transactionTypeFilter', '');
updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', undefined, true);
updateFilter('transactionTypeFilter', undefined, true);
filterModal.closeModal();
},
});
@@ -193,14 +204,9 @@ const InventoryAdjustmentTable = () => {
}, []);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product', val);
};
const handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
@@ -208,38 +214,20 @@ const InventoryAdjustmentTable = () => {
formik.setFieldValue('warehouse', val);
};
const handleFilterTransactionTypeChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const type = val as OptionType | null;
const typeValue = type?.value ? String(type.value) : null;
formik.setFieldValue('transaction_type', typeValue);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find(
(opt) => String(opt.value) === formik.values.product_id
) || null
);
}, [formik.values.product_id, productOptions]);
const transactionTypeValue = useMemo(() => {
if (!formik.values.transaction_type) return null;
return (
transactionTypeOptions.find(
(opt) => String(opt.value) === formik.values.transaction_type
) || null
);
}, [formik.values.transaction_type, transactionTypeOptions]);
const handleFilterTransactionTypeChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('transaction_type', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
transaction_type: tableFilterState.transactionTypeFilter ?? undefined,
});
filterModal.openModal();
formik.validateForm();
};
const {
@@ -276,17 +264,8 @@ const InventoryAdjustmentTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-adjustment-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
@@ -507,6 +486,8 @@ const InventoryAdjustmentTable = () => {
'productSort',
'warehouseSort',
'stockSort',
'productName',
'warehouseName',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
@@ -596,7 +577,7 @@ const InventoryAdjustmentTable = () => {
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
value={formik.values.product}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
@@ -620,7 +601,7 @@ const InventoryAdjustmentTable = () => {
label='Tipe Transaksi'
placeholder='Pilih Tipe Transaksi'
options={transactionTypeOptions}
value={transactionTypeValue}
value={formik.values.transaction_type}
onChange={handleFilterTransactionTypeChange}
isClearable
className={{ wrapper: 'w-full' }}
@@ -630,13 +611,9 @@ const InventoryAdjustmentTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,14 +1,23 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const AdjustmentFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
transaction_type: string().nullable(),
export const AdjustmentFilterSchema = Yup.object().shape({
product: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
transaction_type: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type AdjustmentFilterType = {
product_id: string | null;
transaction_type: string | null;
warehouse: OptionType<number> | null;
product?: OptionType<string>;
warehouse?: OptionType<string>;
transaction_type?: OptionType<string>;
};
@@ -1,14 +1,7 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR, { mutate } from 'swr';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik';
@@ -20,7 +13,6 @@ import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import Button from '@/components/Button';
@@ -108,20 +100,21 @@ const RowOptionsMenu = ({
};
const MovementTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
}>({
initial: {
search: '',
productFilter: '',
warehouseFilter: '',
productFilter: undefined,
warehouseFilter: undefined,
},
paramMap: {
page: 'page',
@@ -129,6 +122,8 @@ const MovementTable = () => {
productFilter: 'product_id',
warehouseFilter: 'warehouse_id',
},
persist: true,
storeName: 'movement-table',
});
// ===== FILTER MODAL STATE =====
@@ -137,19 +132,20 @@ const MovementTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<MovementFilterType>({
initialValues: {
product_id: null,
warehouse_id: null,
product: tableFilterState.productFilter,
warehouse: tableFilterState.warehouseFilter,
},
validationSchema: MovementFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', undefined, true);
filterModal.closeModal();
},
});
@@ -180,47 +176,23 @@ const MovementTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product', val);
};
const handleFilterWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const warehouse = val as OptionType | null;
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
formik.setFieldValue('warehouse_id', warehouseId);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find(
(opt) => String(opt.value) === formik.values.product_id
) || null
);
}, [formik.values.product_id, productOptions]);
const warehouseIdValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find(
(opt) => String(opt.value) === formik.values.warehouse_id
) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
const handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('warehouse', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
});
filterModal.openModal();
formik.validateForm();
};
const [sorting, setSorting] = useState<SortingState>([]);
@@ -255,17 +227,8 @@ const MovementTable = () => {
}
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('movement-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const movementColumns: ColumnDef<Movement>[] = useMemo(
@@ -464,7 +427,7 @@ const MovementTable = () => {
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
value={formik.values.product}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
@@ -476,7 +439,7 @@ const MovementTable = () => {
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={warehouseIdValue}
value={formik.values.warehouse}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
@@ -489,13 +452,9 @@ const MovementTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,11 +1,18 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const MovementFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
export const MovementFilterSchema = Yup.object().shape({
product: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type MovementFilterType = {
product_id: string | null;
warehouse_id: string | null;
product?: OptionType<string>;
warehouse?: OptionType<string>;
};
@@ -56,6 +56,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [productQtyErrorShown, setProductQtyErrorShown] = useState(false);
const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const productStockCacheRef = useRef<
Map<number, { quantity: number; transfer_available_qty?: number }>
>(new Map());
// ===== FORM HANDLERS =====
const createMovementHandler = useCallback(
@@ -337,6 +340,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null
) {
productStockCacheRef.current = new Map();
formik.setFieldValue('products', [
{
product: null,
@@ -399,6 +403,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: [];
}, [productWarehouses]);
useEffect(() => {
productWarehouseOptions.forEach((pw) => {
productStockCacheRef.current.set(pw.product_id, {
quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
});
});
}, [productWarehouseOptions]);
// ===== HELPER FUNCTIONS =====
const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
arrayName: T,
@@ -840,15 +853,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getAvailableStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return (
productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
if (live) return live.transfer_available_qty ?? live.quantity ?? 0;
const cached = productStockCacheRef.current.get(productId);
return cached?.transfer_available_qty ?? cached?.quantity ?? 0;
},
[productWarehouseOptions, type]
);
@@ -856,20 +866,25 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getTotalStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.quantity ?? 0;
if (live) return live.quantity ?? 0;
return productStockCacheRef.current.get(productId)?.quantity ?? 0;
},
[productWarehouseOptions, type]
);
const hasAvailableQty = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.transfer_available_qty !== undefined;
if (live) return live.transfer_available_qty !== undefined;
return (
productStockCacheRef.current.get(productId)?.transfer_available_qty !==
undefined
);
},
[productWarehouseOptions]
);
@@ -4,17 +4,23 @@ import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Modal, { useModal } from '@/components/Modal';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { ProductCategoryApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryProduct } from '@/types/api/inventory/product';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
@@ -71,25 +77,79 @@ const RowOptionsMenu = ({
};
const InventoryProductTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
}>({
initial: {
search: '',
categoryFilter: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
categoryFilter: 'product_category_id',
},
persist: true,
storeName: 'inventory-product-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<{ category?: OptionType<string> }>({
initialValues: { category: tableFilterState.categoryFilter },
validationSchema: Yup.object().shape({
category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}),
onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('categoryFilter', undefined, true);
filterModal.closeModal();
},
});
// ===== CATEGORY OPTIONS =====
const {
setInputValue: setCategoryInputValue,
options: categoryOptions,
isLoadingOptions: isLoadingCategoryOptions,
loadMore: loadMoreCategories,
} = useSelect<ProductCategory>(
filterModal.open ? ProductCategoryApi.basePath : null,
'id',
'name',
'search'
);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
category: tableFilterState.categoryFilter ?? undefined,
});
filterModal.openModal();
};
const handleFilterCategoryChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('category', val);
};
const [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR(
@@ -97,17 +157,8 @@ const InventoryProductTable = () => {
InventoryProductApi.getAllFetcher
);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const columns: ColumnDef<InventoryProduct>[] = useMemo(
@@ -182,96 +233,163 @@ const InventoryProductTable = () => {
);
return (
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.product_stock.create'>
<Button
href='/inventory/product/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Product
</Button>
</RequirePermission>
</div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
<>
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.product_stock.create'>
<Button
href='/inventory/product/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Product
</Button>
</RequirePermission>
</div>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? (
<div className='p-3'>
<InventoryProductTableSkeleton
columns={columns}
icon={
{/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon
icon='heroicons:document-text'
className='text-white'
icon='heroicons:magnifying-glass'
width={20}
height={20}
/>
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
) : (
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.data
: []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? (
<div className='p-3'>
<InventoryProductTableSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.data
: []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Kategori Produk'
placeholder='Pilih Kategori'
options={categoryOptions}
value={formik.values.category}
onChange={handleFilterCategoryChange}
onInputChange={setCategoryInputValue}
isLoading={isLoadingCategoryOptions}
isClearable
onMenuScrollToBottom={loadMoreCategories}
className={{ wrapper: 'w-full' }}
/>
</div>
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
@@ -1,8 +1,16 @@
'use client';
import Card from '@/components/Card';
import { OptionType } from '@/components/input/SelectInput';
import { FormHeader } from '@/components/helper/form/FormHeader';
import ButtonFilter from '@/components/helper/ButtonFilter';
import RequirePermission from '@/components/helper/RequirePermission';
import { useModal } from '@/components/Modal';
import StockLogFilterModal from '@/components/pages/inventory/product/detail/StockLogFilterModal';
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react';
@@ -11,17 +19,34 @@ const InventoryProductDetail = ({
}: {
inventoryProduct?: InventoryProduct;
}) => {
const stockLogs = useMemo(() => {
return (
inventoryProduct?.product_warehouses?.flatMap((warehouse) =>
warehouse.stock_logs.map((log) => ({
...log,
warehouse_name: warehouse.warehouse_name,
warehouse_id: warehouse.warehouse_id,
}))
) || []
);
}, [inventoryProduct]);
const filterModal = useModal();
const { state: filterState, updateFilter } = useTableFilter<{
warehouse_ids: OptionType<number>[];
}>({
initial: {
warehouse_ids: [],
},
persist: true,
storeName: 'inventory-product-stock-log-filter',
});
const filteredProductWarehouses = useMemo(() => {
const warehouses = inventoryProduct?.product_warehouses ?? [];
if (!filterState.warehouse_ids?.length) return warehouses;
const selectedIds = new Set(filterState.warehouse_ids.map((w) => w.value));
return warehouses.filter((pw) => selectedIds.has(pw.warehouse_id));
}, [inventoryProduct?.product_warehouses, filterState.warehouse_ids]);
const filterSubmitHandler = (values: {
warehouse_ids: OptionType<number>[];
}) => {
updateFilter('warehouse_ids', values.warehouse_ids, true);
};
const filterResetHandler = () => {
updateFilter('warehouse_ids', [], true);
};
return (
<div className='flex flex-col gap-4 p-4'>
@@ -114,7 +139,29 @@ const InventoryProductDetail = ({
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
/>
<StockLogTable stockLogs={stockLogs} />
<RequirePermission permissions={'lti.inventory.stock_log.list'}>
<div className='flex justify-end'>
<ButtonFilter
values={{ warehouse_ids: filterState.warehouse_ids }}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
</div>
{filteredProductWarehouses.map((productWarehouse) => (
<StockLogTable
key={productWarehouse.id}
productWarehouse={productWarehouse}
/>
))}
</RequirePermission>
<StockLogFilterModal
ref={filterModal.ref}
productWarehouses={inventoryProduct?.product_warehouses ?? []}
initialValues={{ warehouse_ids: filterState.warehouse_ids }}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
</div>
);
};
@@ -0,0 +1,115 @@
'use client';
import Button from '@/components/Button';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { OptionType } from '@/components/input/SelectInput';
import Modal from '@/components/Modal';
import { ProductWarehouseStock } from '@/types/api/inventory/product';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import { RefObject, useCallback } from 'react';
interface StockLogFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
productWarehouses: ProductWarehouseStock[];
initialValues: {
warehouse_ids: OptionType<number>[];
};
onSubmit: (values: { warehouse_ids: OptionType<number>[] }) => void;
onReset: () => void;
}
const StockLogFilterModal = ({
ref,
productWarehouses,
initialValues,
onSubmit,
onReset,
}: StockLogFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
const warehouseOptions: OptionType<number>[] = productWarehouses.map(
(pw) => ({
label: pw.warehouse_name,
value: pw.warehouse_id,
})
);
const formik = useFormik({
initialValues,
enableReinitialize: true,
onSubmit: (values) => {
onSubmit(values);
closeModalHandler();
},
});
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({ values: { warehouse_ids: [] } });
onReset();
closeModalHandler();
}, [resetForm, onReset]);
return (
<Modal ref={ref} className={{ modalBox: 'p-0 rounded-xl' }}>
<form
onSubmit={formik.handleSubmit}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
<div className='p-4 flex items-center justify-between gap-2 border-b border-gray-300'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Stock Log</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputCheckbox
label='Gudang'
isClearable
placeholder='Pilih gudang'
options={warehouseOptions}
value={formik.values.warehouse_ids}
onChange={(val) =>
formik.setFieldValue('warehouse_ids', val as OptionType<number>[])
}
isMulti
/>
</div>
<div className='p-4 flex justify-between gap-4 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='ghost'
color='none'
className='p-3 rounded-lg text-base-content/65'
>
Reset Filter
</Button>
<Button
type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default StockLogFilterModal;
@@ -1,95 +1,183 @@
import Button from '@/components/Button';
import Card from '@/components/Card';
import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLog } from '@/types/api/inventory/product';
import { StockLogApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock, StockLog } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
import { FileDown } from 'lucide-react';
import toast from 'react-hot-toast';
import { useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
const stockLogTableColumns: (warehouseName: string) => ColumnDef<StockLog>[] = (
warehouseName
) => [
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Gudang',
accessorKey: 'warehouse_name',
cell: warehouseName,
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
];
const StockLogTable = ({
stockLogs,
productWarehouse,
}: {
stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[];
productWarehouse: ProductWarehouseStock;
}) => {
const [isExportLoading, setIsExportLoading] = useState(false);
const [hasBeenVisible, setHasBeenVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHasBeenVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1 }
);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const {
state: tableFilterState,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
product_warehouse_id: productWarehouse.id,
},
});
const handleExportExcel = async () => {
setIsExportLoading(true);
try {
await StockLogApi.exportToExcel(
productWarehouse.warehouse_name,
getTableFilterQueryString()
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExportLoading(false);
}
};
const { data: stockLogsResponse, isLoading: isLoadingStockLogs } = useSWR(
hasBeenVisible
? `${StockLogApi.basePath}${getTableFilterQueryString()}`
: null,
StockLogApi.getAllFetcher
);
const stockLogs = isResponseSuccess(stockLogsResponse)
? stockLogsResponse.data
: [];
return (
<Card
title='Informasi Stock Produk'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<StockLog>
data={stockLogs}
columns={[
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
]}
<div ref={containerRef}>
<Card
title={`Informasi Stock Produk - ${productWarehouse.warehouse_name}`}
collapsible
variant='bordered'
className={{
containerClassName: 'mt-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
wrapper: 'w-full',
}}
/>
</Card>
>
<div className='flex justify-end px-6 pt-4'>
<Button onClick={handleExportExcel} isLoading={isExportLoading}>
<FileDown size={16} />
Export Excel
</Button>
</div>
<Table<StockLog>
data={stockLogs}
columns={stockLogTableColumns(productWarehouse.warehouse_name)}
page={tableFilterState.page ?? 0}
pageSize={tableFilterState.pageSize}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoadingStockLogs}
totalItems={
isResponseSuccess(stockLogsResponse)
? stockLogsResponse.meta?.total_results
: 0
}
className={{
containerClassName: 'mt-4 mb-0',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</Card>
</div>
);
};
@@ -1,13 +1,42 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
const stockProductWarehouseTableColumns: ColumnDef<ProductWarehouseStock>[] = [
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
];
const StockProductWarehouseTable = ({
productWarehouseStock,
}: {
productWarehouseStock?: ProductWarehouseStock[];
}) => {
const { state: tableFilterState, setPage, setPageSize } = useTableFilter();
return (
<Card
title='Informasi Gudang'
@@ -19,32 +48,14 @@ const StockProductWarehouseTable = ({
>
<Table<ProductWarehouseStock>
data={productWarehouseStock ?? []}
columns={[
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
]}
columns={stockProductWarehouseTableColumns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page ?? 0}
totalItems={productWarehouseStock?.length ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
className={{
containerClassName: 'mt-6',
containerClassName: 'mt-6 mb-0',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
@@ -847,9 +847,14 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
}
}}
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected}
disabled={deliveryRejected || isLoading}
isLoading={isLoading}
>
Approve
{marketing?.data?.latest_approval?.step_number === 1 &&
'Approve'}
{marketing?.data?.latest_approval?.step_number === 2 &&
'Deliver Item'}
</Button>
</div>
)}
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useMemo } from 'react';
import { RefObject, useCallback, useMemo } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
@@ -17,20 +17,31 @@ import {
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi } from '@/services/api/master-data';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Product } from '@/types/api/master-data/product';
interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: MarketingFilter) => void;
onReset?: () => void;
initialValues?: {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
}
const MarketingFilterModal = ({
ref,
onSubmit,
onReset,
initialValues,
}: MarketingFilterModal) => {
const closeModalHandler = () => {
ref.current?.close();
@@ -38,36 +49,13 @@ const MarketingFilterModal = ({
// ===== OPTIONS =====
const {
rawData: productsRawData,
options: productsOptions,
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect<BaseMarketing>(
MarketingApi.basePath,
'id',
'so_number',
'search'
);
const productsOptions = useMemo(() => {
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
const productsMap = new Map<number, { value: number; label: string }>();
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
const product = so.product_warehouse?.product;
if (product?.id && product?.name) {
productsMap.set(product.id, {
value: product.id,
label: product.name,
});
}
});
});
return Array.from(productsMap.values());
}, [productsRawData]);
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
include_all: 'true',
});
const {
options: customersOptions,
@@ -78,6 +66,19 @@ const MarketingFilterModal = ({
has_marketing: 'true',
});
const {
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
setInputValue: setProjectFlockInputValue,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search'
);
const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
@@ -87,18 +88,29 @@ const MarketingFilterModal = ({
];
const formik = useFormik<MarketingFilterFormValues>({
initialValues: {
initialValues: initialValues || {
product_ids: [],
status: null,
customer: null,
project_flock: null,
project_flock_kandang: null,
},
validationSchema: MarketingFilterSchema,
onSubmit: async (values) => {
const formattedValues: MarketingFilter = {
product_ids: values.product_ids.map((item) => Number(item.value)),
product_names: values.product_ids.map((item) => item.label),
status: values.status?.value.toString() || '',
status_name: values.status?.label || '-',
customer_id: Number(values.customer?.value),
customer_name: values.customer?.label || '-',
project_flock_id: values.project_flock?.value || undefined,
project_flock_name: values.project_flock?.label,
project_flock_kandang_id:
Number(values.project_flock_kandang?.value) || undefined,
project_flock_kandang_name:
values.project_flock_kandang?.label || undefined,
};
onSubmit?.(formattedValues);
@@ -111,6 +123,22 @@ const MarketingFilterModal = ({
},
});
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({
values: {
product_ids: [],
status: null,
customer: null,
project_flock: null,
project_flock_kandang: null,
},
});
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]);
};
@@ -126,6 +154,27 @@ const MarketingFilterModal = ({
formik.setFieldValue('status', val as OptionType);
};
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
return (
<Modal
ref={ref}
@@ -135,7 +184,7 @@ const MarketingFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -192,6 +241,37 @@ const MarketingFilterModal = ({
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/>
<SelectInput
label='Project Flock'
isClearable
placeholder='Pilih Project Flock'
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
value={formik.values.project_flock}
onChange={(val) => {
formik.setFieldValue(
'project_flock',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
formik.setFieldValue('project_flock_kandang', null);
}}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
/>
<SelectInput
label='Kandang'
isClearable
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
)
}
isDisabled={!formik.values.project_flock}
/>
</div>
{/* Modal Footer */}
+589 -71
View File
@@ -2,26 +2,39 @@
import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
getErrorMessage,
isResponseError,
isResponseSuccess,
} from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import {
BaseSalesOrder,
Marketing,
MarketingFilter,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
import {
CellContext,
ColumnDef,
Row,
SortingState,
Updater,
} from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission';
@@ -154,12 +167,21 @@ const MarketingTable = () => {
);
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [bulkDeliveryDate, setBulkDeliveryDate] = useState('');
const [bulkDeliveryNotes, setBulkDeliveryNotes] = useState('');
const [isSubmittingBulkDelivery, setIsSubmittingBulkDelivery] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const router = useRouter();
const deleteModal = useModal();
const confirmationModal = useModal();
const productsModal = useModal();
const deliveryModal = useModal();
const bulkDeliveryModal = useModal();
const exportProgressInputModal = useModal();
const filterModal = useModal();
const {
@@ -172,8 +194,17 @@ const MarketingTable = () => {
initial: {
search: '',
product_ids: '',
product_names: '',
status: '',
status_name: '',
customer_id: '',
customer_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
@@ -181,9 +212,43 @@ const MarketingTable = () => {
product_ids: 'product_ids',
status: 'status',
customer_id: 'customer_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
sort_by: 'sort_by',
order_by: 'sort_order',
},
excludeKeysFromUrl: [
'product_names',
'status_name',
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'marketing-table',
});
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// ===== FETCH DATA =====
const {
data: marketing,
@@ -198,26 +263,64 @@ const MarketingTable = () => {
const filterSubmitHandler = (values: MarketingFilter) => {
updateFilter(
'product_ids',
values.product_ids?.map((item) => item.toString()).join(',')
values.product_ids?.map((item) => item.toString()).join(','),
true
);
updateFilter('status', values.status ? values.status.toString() : '');
updateFilter('product_names', values.product_names?.join(','));
updateFilter('status', values.status ? values.status.toString() : '', true);
updateFilter('status_name', values.status_name, true);
updateFilter(
'customer_id',
values.customer_id ? values.customer_id.toString() : ''
values.customer_id ? values.customer_id.toString() : '',
true
);
updateFilter('customer_name', values.customer_name, true);
updateFilter(
'project_flock_id',
values.project_flock_id ? values.project_flock_id.toString() : '',
true
);
updateFilter('project_flock_name', values.project_flock_name ?? '', true);
updateFilter(
'project_flock_kandang_id',
values.project_flock_kandang_id
? values.project_flock_kandang_id.toString()
: '',
true
);
updateFilter(
'project_flock_kandang_name',
values.project_flock_kandang_name ?? '',
true
);
};
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeliveryLoading, setIsDeliveryLoading] = useState(false);
const filterResetHandler = () => {
updateFilter('product_ids', '');
updateFilter('status', '');
updateFilter('customer_id', '');
updateFilter('product_ids', '', true);
updateFilter('product_names', '', true);
updateFilter('status', '', true);
updateFilter('status_name', '', true);
updateFilter('customer_id', '', true);
updateFilter('customer_name', '', true);
updateFilter('project_flock_id', '', true);
updateFilter('project_flock_name', '', true);
updateFilter('project_flock_kandang_id', '', true);
updateFilter('project_flock_kandang_name', '', true);
};
const approveClickHandler = () => {
setApproveAction('APPROVED');
if (selectedApprovalStep === 2) {
bulkDeliveryModal.openModal();
return;
}
confirmationModal.openModal();
};
@@ -226,10 +329,13 @@ const MarketingTable = () => {
confirmationModal.openModal();
};
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
const productsClickHandler = useCallback(
(item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
},
[productsModal]
);
const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete(
@@ -251,75 +357,226 @@ const MarketingTable = () => {
const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()]
);
const selectedApprovalStep =
selectedRowsData.length > 0
? selectedRowsData[0].latest_approval.step_number
: null;
const hasApprovable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const hasRejectable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const eligibleSelectedRows = selectedRowsData.filter((row) => {
const approval = row.latest_approval;
if (approval.action === 'REJECTED') {
return false;
}
if (selectedApprovalStep === null) {
return approval.step_number === 1 || approval.step_number === 2;
}
return approval.step_number === selectedApprovalStep;
});
const hasApprovable = eligibleSelectedRows.length > 0;
const hasRejectable = eligibleSelectedRows.length > 0;
const disableApprove = !hasApprovable;
const disableReject = !hasRejectable;
const idsToProcess =
approveAction === 'APPROVED'
? selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id)
: selectedRowsData
.filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
const idsToProcess = eligibleSelectedRows.map((row) => row.id);
const nextApprovalStatus =
selectedApprovalStep === 1
? 'SALES_ORDER'
: selectedApprovalStep === 2
? 'DELIVERY_ORDER'
: null;
const productIds = tableFilterState.product_ids
? tableFilterState.product_ids
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const productLabels = tableFilterState.product_names
? tableFilterState.product_names
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const marketingFilterInitialValues = {
product_ids: productIds.map((value, idx) => ({
value: Number(value),
label: productLabels[idx] || '-',
})),
status: tableFilterState.status
? {
value: tableFilterState.status,
label: tableFilterState.status_name,
}
: null,
customer: tableFilterState.customer_id
? {
value: Number(tableFilterState.customer_id),
label: tableFilterState.customer_name,
}
: null,
project_flock: tableFilterState.project_flock_id
? {
value: Number(tableFilterState.project_flock_id),
label: tableFilterState.project_flock_name,
}
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? {
value: Number(tableFilterState.project_flock_kandang_id),
label: tableFilterState.project_flock_kandang_name,
}
: null,
};
const approveMarketingHandler = async (notes: string) => {
let idsToProcess: number[] = [];
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal();
return;
}
const approveMarketingRes = await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (approveAction === 'APPROVED' && selectedApprovalStep !== 1) {
toast.error('Approve tahap ini harus menggunakan tanggal pengiriman.');
confirmationModal.closeModal();
return;
}
if (isResponseSuccess(approveMarketingRes)) {
if (approveAction === 'APPROVED' && !nextApprovalStatus) {
toast.error('Status approval berikutnya tidak valid.');
confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string);
return;
}
setIsApproveLoading(true);
try {
const approveMarketingRes: BaseApiResponse<unknown> | undefined =
approveAction === 'APPROVED'
? await MarketingApi.bulkApprovals(
idsToProcess,
nextApprovalStatus as 'SALES_ORDER' | 'DELIVERY_ORDER',
'',
notes || `APPROVED marketing ${idsToProcess.join(', ')}`
)
: await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string);
setRowSelection({});
}
refreshMarketing();
} finally {
setIsApproveLoading(false);
}
};
const bulkDeliveryDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkDeliveryDate(e.target.value);
};
const bulkDeliveryNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkDeliveryNotes(e.target.value);
};
const submitBulkDeliveryApprovalHandler = async (
selectedIds: number[],
deliveryDate: string,
notes: string
) => {
if (selectedIds.length === 0) {
toast.error('Tidak ada data yang valid untuk diproses.');
return;
}
if (!deliveryDate) {
toast.error('Tanggal pengiriman wajib diisi.');
return;
}
setIsSubmittingBulkDelivery(true);
try {
const bulkDeliveryApprovalRes = await MarketingApi.bulkApprovals(
selectedIds,
'DELIVERY_ORDER',
deliveryDate,
notes || `APPROVED delivery marketing ${selectedIds.join(', ')}`
);
if (isResponseError(bulkDeliveryApprovalRes)) {
toast.error(bulkDeliveryApprovalRes?.message as string);
return;
}
if (!isResponseSuccess(bulkDeliveryApprovalRes)) {
toast.error('Gagal memproses bulk approve delivery.');
return;
}
toast.success(bulkDeliveryApprovalRes?.message as string);
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
setRowSelection({});
refreshMarketing();
} finally {
setIsSubmittingBulkDelivery(false);
}
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
};
const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
deliveryModal.closeModal();
toast.success(res?.message as string);
refreshMarketing?.();
router.push(
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
);
setIsDeliveryLoading(true);
try {
const res = await SalesOrderApi.delivery(
selectedItem?.id as number,
notes
);
deliveryModal.closeModal();
toast.success(res?.message as string);
refreshMarketing?.();
router.push(
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
);
} finally {
setIsDeliveryLoading(false);
}
};
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
};
const getRowCanSelect = useCallback(
(row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
const isSelectableStep =
approval?.step_number === 1 || approval?.step_number === 2;
if (!isSelectableStep || approval?.action === 'REJECTED') {
return false;
}
if (selectedApprovalStep === null) {
return true;
}
return approval?.step_number === selectedApprovalStep;
},
[selectedApprovalStep]
);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
@@ -329,6 +586,53 @@ const MarketingTable = () => {
setIsLoadingExportingToExcel(false);
};
const resetExportProgressForm = () => {
setExportProgressStartDate('');
setExportProgressEndDate('');
};
const exportProgressStartDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressStartDate(e.target.value);
};
const exportProgressEndDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressEndDate(e.target.value);
};
const exportProgressInputToExcelClickHandler = () => {
resetExportProgressForm();
exportProgressInputModal.openModal();
};
const submitExportProgressInputHandler = async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await MarketingApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
};
const columns = useMemo<ColumnDef<Marketing>[]>(() => {
return [
{
@@ -336,7 +640,22 @@ const MarketingTable = () => {
size: 1,
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(getRowCanSelect);
const stepForBulkSelection =
selectedApprovalStep ??
allRows.find(getRowCanSelect)?.original.latest_approval.step_number;
const selectableRows = allRows.filter((row) => {
if (!getRowCanSelect(row)) {
return false;
}
if (!stepForBulkSelection) {
return false;
}
return (
row.original.latest_approval.step_number === stepForBulkSelection
);
});
const allSelected =
selectableRows.length > 0 &&
@@ -378,7 +697,7 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'so_do_number',
accessorKey: 'so_number',
header: 'No. Order',
cell: (props) => {
return props.row.original.do_number
@@ -394,7 +713,7 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'approval.step_name',
accessorKey: 'status',
header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
@@ -429,10 +748,12 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'customer.name',
accessorKey: 'customer',
header: 'Customer',
cell: (props) => props.row.original.customer.name,
},
{
accessorKey: 'grand_total',
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
@@ -449,6 +770,7 @@ const MarketingTable = () => {
{
accessorKey: 'marketing_products.length',
header: 'Product Details',
enableSorting: false,
cell: (props) => {
if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) {
@@ -470,6 +792,14 @@ const MarketingTable = () => {
}
},
},
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM yyyy')
: '-',
},
{
id: 'actions',
maxSize: 80,
@@ -504,7 +834,13 @@ const MarketingTable = () => {
},
},
];
}, []);
}, [
deleteModal,
deliveryModal,
getRowCanSelect,
productsClickHandler,
selectedApprovalStep,
]);
return (
<>
@@ -527,7 +863,7 @@ const MarketingTable = () => {
</RequirePermission>
{idsToProcess.length > 0 && (
<>
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px'></div>
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px' />
<RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button
color='error'
@@ -541,7 +877,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Reject
Reject ({idsToProcess.length} Item)
</Button>
</RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
@@ -557,7 +893,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Approve
Approve ({idsToProcess.length} Item)
</Button>
</RequirePermission>
</>
@@ -566,7 +902,18 @@ const MarketingTable = () => {
<div className='flex flex-row gap-3'>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
excludeFields={[
'page',
'pageSize',
'search',
'product_names',
'status_name',
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'sort_by',
'order_by',
]}
onClick={() => {
filterModal.openModal();
}}
@@ -612,7 +959,17 @@ const MarketingTable = () => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
Ekspor ke Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
@@ -646,6 +1003,9 @@ const MarketingTable = () => {
columns={columns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
totalItems={
isResponseSuccess(marketing)
? marketing?.meta?.total_results
@@ -677,14 +1037,16 @@ const MarketingTable = () => {
<ConfirmationModalWithNotes
ref={confirmationModal.ref}
type={approveAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan (${idsToProcess.length} data)?`}
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan tahap ${selectedApprovalStep ?? '-'} (${idsToProcess.length} data)?`}
secondaryButton={{
text: 'Tidak',
isLoading: isApproveLoading,
onClick: confirmationModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: approveAction === 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: approveMarketingHandler,
}}
/>
@@ -708,14 +1070,169 @@ const MarketingTable = () => {
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
secondaryButton={{
text: 'Tidak',
isLoading: isDeliveryLoading,
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isDeliveryLoading,
onClick: confirmationModalDeliveryClickHandler,
}}
/>
<Modal
ref={bulkDeliveryModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Bulk Approve Delivery
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<p className='text-sm text-base-content/70'>
Pilih tanggal pengiriman untuk approve {idsToProcess.length} data
penjualan tahap 2.
</p>
<DateInput
name='bulk_delivery_date'
label='Tanggal Pengiriman'
value={bulkDeliveryDate}
onChange={bulkDeliveryDateChangeHandler}
isNestedModal
required
/>
<TextArea
name='bulk_delivery_notes'
label='Catatan'
placeholder='Masukkan catatan approval...'
value={bulkDeliveryNotes}
onChange={bulkDeliveryNotesChangeHandler}
rows={4}
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
disabled={isSubmittingBulkDelivery}
onClick={() => {
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
isLoading={isSubmittingBulkDelivery}
disabled={isSubmittingBulkDelivery}
onClick={() =>
submitBulkDeliveryApprovalHandler(
idsToProcess,
bulkDeliveryDate,
bulkDeliveryNotes
)
}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<Modal
ref={productsModal.ref}
className={{
@@ -777,6 +1294,7 @@ const MarketingTable = () => {
ref={filterModal.ref}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
initialValues={marketingFilterInitialValues}
/>
</>
);
@@ -246,6 +246,7 @@ const SalesOrderFormModal = ({
})
.filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload);
switch (modalAction) {
case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -261,11 +262,7 @@ const SalesOrderFormModal = ({
// ===== Formik Error List =====
const { formErrorList, setFormErrorList, close, handleFormSubmit } =
useFormikErrorList(formik, {
onAfterSubmit: () => {
router.push('/marketing');
},
});
useFormikErrorList(formik);
// ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
@@ -5,10 +5,14 @@ export const MarketingFilterSchema = object({
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
status: mixed<OptionType<string>>().nullable(),
customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: mixed<OptionType<number>>().nullable(),
});
export type MarketingFilterFormValues = {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
@@ -71,14 +71,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
.required('Pengiriman wajib diisi!')
.test(
'at-least-one-valid-row',
'Minimal harus ada satu baris pengiriman yang lengkap diisi!',
'Seluruh data pengiriman harus diisi lengkap!',
function (items) {
if (!items || items.length === 0) return false;
// VALIDASI: minimal 1 item valid full
// VALIDASI: seluruh item harus valid full
const itemSchema = DeliveryOrderProductSchema;
const hasValidItem = items.some((item) => {
const hasValidItem = items.every((item) => {
if (!item) return false;
return itemSchema.isValidSync(item, { abortEarly: true });
});
@@ -123,8 +123,17 @@ export const SalesProductToFieldValues = (
total_price: product.total_price,
marketing_type: product.marketing_type
? {
value: product.marketing_type,
label: formatTitleCase(product.marketing_type),
value:
product.marketing_type === 'AYAM' ||
product.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: product.marketing_type,
label: formatTitleCase(
product.marketing_type === 'AYAM' ||
product.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: product.marketing_type
),
}
: null,
convertion_unit: product.convertion_unit
@@ -144,9 +153,11 @@ export const DeliveryProductToFieldValues = (
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => {
const salesOrder = salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
);
const salesOrder =
salesOrders.find((so) => so.id === item.marketing_product_id) ??
salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
);
const warehouseOption = {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
@@ -180,11 +191,20 @@ export const DeliveryProductToFieldValues = (
vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number,
marketing_product_id: salesOrder?.id,
marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
marketing_type: salesOrder?.marketing_type
? {
value: salesOrder?.marketing_type,
label: formatTitleCase(salesOrder?.marketing_type),
value:
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: salesOrder?.marketing_type,
label: formatTitleCase(
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: salesOrder?.marketing_type
),
}
: null,
convertion_unit: salesOrder?.convertion_unit
@@ -194,7 +214,7 @@ export const DeliveryProductToFieldValues = (
}
: null,
marketing_product: {
id: salesOrder?.id,
id: item.marketing_product_id ?? salesOrder?.id,
vehicle_number: item.vehicle_number,
warehouse_id: item.product_warehouse.warehouse.id,
warehouse: warehouseOption,
@@ -146,15 +146,6 @@ const DeliveryOrderProductForm = ({
);
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
// Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => {
@@ -190,12 +181,19 @@ const DeliveryOrderProductForm = ({
const deliveryOrder = useMemo(() => {
if (!hasDeliveryOrder || !deliveryOrders) return null;
const marketingProductId =
initialValues?.marketing_product_id ?? initialValues?.id;
for (const doItem of deliveryOrders) {
const found = doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
const found =
doItem.deliveries.find(
(d) => d.marketing_product_id === marketingProductId
) ??
doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
if (found) {
return {
...found,
@@ -403,7 +401,10 @@ const DeliveryOrderProductForm = ({
useEffect(() => {
if (initialValues) {
if (!Boolean(initialValues.qty)) {
if (
!Boolean(initialValues.qty) &&
!Boolean(initialValues.marketing_product_id)
) {
handleResetForm();
} else {
setFormikValues({
@@ -413,7 +414,7 @@ const DeliveryOrderProductForm = ({
});
if (initialValues?.marketing_product_id) {
setSelectedProduct({
value: initialValues?.id,
value: initialValues?.marketing_product_id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
} as OptionType);
}
@@ -430,7 +431,8 @@ const DeliveryOrderProductForm = ({
handleBlurField(currentInput);
formik.setFieldValue(
'uom',
isResponseSuccess(productData) ? productData?.data?.uom?.name : ''
initialValues?.marketing_product?.product_warehouse_data?.product?.uom
?.name ?? ''
);
},
}
@@ -803,9 +805,8 @@ const DeliveryOrderProductForm = ({
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
{initialValues?.marketing_product?.product_warehouse_data
?.product?.uom?.name ?? ''}
</span>
</div>
}
@@ -816,9 +817,8 @@ const DeliveryOrderProductForm = ({
(item) => item.id === formik.values.marketing_product_id
)?.qty +
' ' +
(isResponseSuccess(productData)
? productData?.data?.uom.name
: '')
(initialValues?.marketing_product?.product_warehouse_data
?.product?.uom?.name ?? '')
: ''
}
/>
@@ -252,6 +252,11 @@ const SalesOrderProductForm = ({
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity);
if (productWarehouse?.quantity) {
handleFieldChange('qty', productWarehouse?.quantity);
}
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if (
productWarehouse?.week !== undefined &&
@@ -124,7 +124,7 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty
{item.qty !== undefined && item.qty !== null && item.qty !== ''
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
: '-'}
</td>
@@ -273,7 +273,7 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty
{item.qty !== undefined && item.qty !== null && item.qty !== ''
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
: '-'}
</td>
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const AreasTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const AreasTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'areas-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -137,17 +134,8 @@ const AreasTable = () => {
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('areas-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const BanksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const BanksTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'banks-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -137,17 +134,8 @@ const BanksTable = () => {
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('banks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const CustomersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const CustomersTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'customers-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -139,17 +136,8 @@ const CustomersTable = () => {
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('customers-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -201,6 +189,11 @@ const CustomersTable = () => {
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{
header: 'Aksi',
cell: (props: CellContext<Customer, unknown>) => {
@@ -27,6 +27,9 @@ export const CustomerFormSchema = Yup.object({
.email('Format email tidak valid!')
.required('Email wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'),
@@ -142,6 +142,7 @@ const CustomerForm = ({
},
type: normalizeType(initialValues?.type),
address: initialValues?.address ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '',
};
}, [initialValues]);
@@ -164,6 +165,7 @@ const CustomerForm = ({
pic_id: values.picId,
type: (values.type as OptionType).value as string,
address: values.address,
bank_name: values.bank_name,
account_number: values.account_number,
};
@@ -286,6 +288,22 @@ const CustomerForm = ({
errorMessage={formik.errors.phone}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank customer'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Flock } from '@/types/api/master-data/flock';
import { FlockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const FlockTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,12 +109,14 @@ const FlockTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'flock-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -139,17 +136,8 @@ const FlockTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('flocks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -35,7 +28,6 @@ import { User } from '@/types/api/api-general';
import { formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
KandangFilterSchema,
KandangFilterType,
@@ -122,20 +114,21 @@ const RowOptionsMenu = ({
};
const KandangsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: {
search: '',
locationFilter: '',
picFilter: '',
locationFilter: undefined,
picFilter: undefined,
},
paramMap: {
page: 'page',
@@ -143,6 +136,8 @@ const KandangsTable = () => {
locationFilter: 'location_id',
picFilter: 'pic_id',
},
persist: true,
storeName: 'kandangs-table',
});
// ===== FILTER MODAL STATE =====
@@ -151,22 +146,34 @@ const KandangsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<KandangFilterType>({
initialValues: {
location_id: null,
pic_id: null,
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
},
validationSchema: KandangFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('locationFilter', values.location_id || '');
updateFilter('picFilter', values.pic_id || '');
updateFilter('locationFilter', values.location || undefined, true);
updateFilter('picFilter', values.pic || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('locationFilter', '');
updateFilter('picFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('locationFilter', undefined, true);
updateFilter('picFilter', undefined, true);
formik.resetForm({
values: {
location: undefined,
pic: undefined,
},
});
filterModal.closeModal();
};
const { setFieldValue } = formik;
// ===== LOCATION OPTIONS =====
const {
setInputValue: setLocationInputValue,
@@ -194,43 +201,15 @@ const KandangsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location?.value ? String(location.value) : null;
const handleFilterLocationChange = (
val: OptionType | OptionType[] | null
) => {
setFieldValue('location', val);
};
formik.setFieldValue('location_id', locationId);
},
[formik]
);
const handleFilterPicChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const pic = val as OptionType | null;
const picId = pic?.value ? String(pic.value) : null;
formik.setFieldValue('pic_id', picId);
},
[formik]
);
// ===== FILTER HELPERS =====
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const picIdValue = useMemo(() => {
if (!formik.values.pic_id) return null;
return (
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
null
);
}, [formik.values.pic_id, picOptions]);
const handleFilterPicChange = (val: OptionType | OptionType[] | null) => {
setFieldValue('pic', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -255,17 +234,8 @@ const KandangsTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('kandangs-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -475,13 +445,13 @@ const KandangsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationIdValue}
value={formik.values.location}
onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
@@ -494,7 +464,7 @@ const KandangsTable = () => {
label='PIC'
placeholder='Pilih PIC'
options={picOptions}
value={picIdValue}
value={formik.values.pic}
onChange={handleFilterPicChange}
onInputChange={setPicInputValue}
isLoading={isLoadingPicOptions}
@@ -510,17 +480,14 @@ const KandangsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
disabled={!formik.isValid}
>
Apply Filter
</Button>
@@ -1,11 +1,19 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const KandangFilterSchema = object().shape({
location_id: string().nullable(),
pic_id: string().nullable(),
export const KandangFilterSchema = Yup.object().shape({
location: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
pic: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type KandangFilterType = {
location_id: string | null;
pic_id: string | null;
location?: OptionType<string>;
pic?: OptionType<string>;
};
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -32,7 +25,6 @@ import { Area } from '@/types/api/master-data/area';
import { LocationApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
LocationFilterSchema,
LocationFilterType,
@@ -118,25 +110,27 @@ const RowOptionsMenu = ({
};
const LocationsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
areaFilter?: OptionType<string>;
}>({
initial: {
search: '',
areaFilter: '',
areaFilter: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
areaFilter: 'area_id',
},
persist: true,
storeName: 'locations-table',
});
// ===== FILTER MODAL STATE =====
@@ -145,19 +139,28 @@ const LocationsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<LocationFilterType>({
initialValues: {
area_id: null,
area: tableFilterState.areaFilter,
},
validationSchema: LocationFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '');
updateFilter('areaFilter', values.area || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('areaFilter', undefined, true);
formik.resetForm({
values: {
area: undefined,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS =====
const {
setInputValue: setAreaInputValue,
@@ -172,24 +175,9 @@ const LocationsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterAreaChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const area = val as OptionType | null;
const areaId = area?.value ? String(area.value) : null;
formik.setFieldValue('area_id', areaId);
},
[formik]
);
// ===== FILTER HELPERS =====
const areaIdValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('area', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -212,19 +200,10 @@ const LocationsTable = () => {
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('locations-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -425,13 +404,13 @@ const LocationsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={areaIdValue}
value={formik.values.area}
onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions}
@@ -447,10 +426,7 @@ const LocationsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
@@ -1,9 +1,13 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const LocationFilterSchema = object().shape({
area_id: string().nullable(),
export const LocationFilterSchema = Yup.object().shape({
area: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type LocationFilterType = {
area_id: string | null;
area?: OptionType<string>;
};
@@ -20,8 +20,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const NonstocksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,22 +109,16 @@ const NonstocksTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'nonstock-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('nonstocks-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -148,8 +137,7 @@ const NonstocksTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,7 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -21,7 +20,6 @@ import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const ProductCategoryTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -120,12 +115,10 @@ const ProductCategoryTable = () => {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'product-category-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -144,8 +137,7 @@ const ProductCategoryTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -214,10 +206,6 @@ const ProductCategoryTable = () => {
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
useEffect(() => {
setTableState('product-category-table', pathname);
}, [pathname, setTableState]);
return (
<>
<div className='w-full'>
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -33,7 +26,6 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
import { formatCurrency } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
ProductFilterSchema,
ProductFilterType,
@@ -119,25 +111,27 @@ const RowOptionsMenu = ({
};
const ProductsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
productCategoryFilter?: OptionType<string>;
}>({
initial: {
search: '',
productCategoryFilter: '',
productCategoryFilter: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
productCategoryFilter: 'product_category_id',
},
persist: true,
storeName: 'product-table',
});
// ===== FILTER MODAL STATE =====
@@ -146,19 +140,32 @@ const ProductsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<ProductFilterType>({
initialValues: {
product_category_id: null,
product_category: tableFilterState.productCategoryFilter,
},
validationSchema: ProductFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productCategoryFilter', values.product_category_id || '');
updateFilter(
'productCategoryFilter',
values.product_category || undefined,
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productCategoryFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('productCategoryFilter', undefined, true);
formik.resetForm({
values: {
product_category: undefined,
},
});
filterModal.closeModal();
};
// ===== PRODUCT CATEGORY OPTIONS =====
const {
setInputValue: setProductCategoryInputValue,
@@ -173,25 +180,11 @@ const ProductsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterProductCategoryChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const category = val as OptionType | null;
const categoryId = category?.value ? String(category.value) : null;
formik.setFieldValue('product_category_id', categoryId);
},
[formik]
);
// ===== FILTER HELPERS =====
const productCategoryIdValue = useMemo(() => {
if (!formik.values.product_category_id) return null;
return (
productCategoryOptions.find(
(opt) => String(opt.value) === formik.values.product_category_id
) || null
);
}, [formik.values.product_category_id, productCategoryOptions]);
const handleFilterProductCategoryChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('product_category', val);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -199,10 +192,6 @@ const ProductsTable = () => {
formik.validateForm();
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -220,13 +209,8 @@ const ProductsTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setTableState('product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -477,13 +461,13 @@ const ProductsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Kategori Produk'
placeholder='Pilih Kategori Produk'
options={productCategoryOptions}
value={productCategoryIdValue}
value={formik.values.product_category}
onChange={handleFilterProductCategoryChange}
onInputChange={setProductCategoryInputValue}
isLoading={isLoadingProductCategoryOptions}
@@ -499,10 +483,7 @@ const ProductsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
@@ -1,9 +1,13 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const ProductFilterSchema = object().shape({
product_category_id: string().nullable(),
export const ProductFilterSchema = Yup.object().shape({
product_category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type ProductFilterType = {
product_category_id: string | null;
product_category?: OptionType<string>;
};
@@ -128,27 +128,44 @@ const ProductionStandardTable = () => {
pageSize: 'limit',
projectCategoryFilter: 'project_category',
},
persist: true,
storeName: 'production-standard-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
const filterInitialValues = useMemo<ProductionStandardFilterType>(
() => ({
project_category: tableFilterState.projectCategoryFilter || null,
}),
[tableFilterState.projectCategoryFilter]
);
// ===== FORMIK SETUP =====
const formik = useFormik<ProductionStandardFilterType>({
initialValues: {
project_category: null,
},
initialValues: filterInitialValues,
validationSchema: ProductionStandardFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('projectCategoryFilter', values.project_category || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('projectCategoryFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('projectCategoryFilter', '', true);
formik.resetForm({
values: {
project_category: null,
},
});
filterModal.closeModal();
};
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
const projectCategoryOptions = useMemo(
() => [
@@ -381,7 +398,7 @@ const ProductionStandardTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio
label='Kategori'
@@ -397,13 +414,9 @@ const ProductionStandardTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -7,7 +7,6 @@ import {
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -30,7 +29,7 @@ import { Supplier } from '@/types/api/master-data/supplier';
import { SupplierApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
SupplierFilterSchema,
SupplierFilterType,
@@ -117,20 +116,21 @@ const RowOptionsMenu = ({
};
const SuppliersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
flagFilter?: string;
}>({
initial: {
search: '',
categoryFilter: '',
flagFilter: '',
categoryFilter: undefined,
flagFilter: undefined,
},
paramMap: {
page: 'page',
@@ -138,6 +138,8 @@ const SuppliersTable = () => {
categoryFilter: 'category_id',
flagFilter: 'flag',
},
persist: true,
storeName: 'supplier-table',
});
// ===== FILTER MODAL STATE =====
@@ -146,26 +148,33 @@ const SuppliersTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<SupplierFilterType>({
initialValues: {
category_id: null,
flag: false,
category: tableFilterState.categoryFilter,
flag: tableFilterState.flagFilter === 'EKSPEDISI',
},
validationSchema: SupplierFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category_id || '');
updateFilter(
'flagFilter',
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
);
updateFilter('categoryFilter', values.category || undefined, true);
updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('categoryFilter', '');
updateFilter('flagFilter', '');
formik.setFieldValue('flag', false);
},
});
const formikResetHandler = () => {
updateFilter('categoryFilter', undefined, true);
updateFilter('flagFilter', '', true);
formik.resetForm({
values: {
category: undefined,
flag: false,
},
});
filterModal.closeModal();
};
const { setFieldValue } = formik;
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
@@ -187,15 +196,11 @@ const SuppliersTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterCategoryChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType | null;
const categoryId = option?.value ? String(option.value) : null;
setFieldValue('category_id', categoryId);
},
[setFieldValue]
);
const handleFilterCategoryChange = (
val: OptionType | OptionType[] | null
) => {
setFieldValue('category', val);
};
const handleFilterFlagChange = useCallback(
(val: OptionType | OptionType[] | null) => {
@@ -213,13 +218,13 @@ const SuppliersTable = () => {
);
// ===== FILTER HELPERS =====
const categoryIdValue = useMemo(() => {
if (!formik.values.category_id) return null;
return (
categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
null
);
}, [formik.values.category_id, categoryOptions]);
// const categoryIdValue = useMemo(() => {
// if (!formik.values.category_id) return null;
// return (
// categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
// null
// );
// }, [formik.values.category_id, categoryOptions]);
const flagValue = useMemo(() => {
if (formik.values.flag === null) return null;
@@ -243,14 +248,6 @@ const SuppliersTable = () => {
}
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('suppliers-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -269,8 +266,7 @@ const SuppliersTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -330,6 +326,11 @@ const SuppliersTable = () => {
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{
accessorKey: 'address',
header: 'Alamat',
@@ -491,13 +492,13 @@ const SuppliersTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={categoryIdValue}
value={formik.values.category}
onChange={handleFilterCategoryChange}
isClearable
className={{ wrapper: 'w-full' }}
@@ -517,13 +518,9 @@ const SuppliersTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,11 +1,16 @@
import { string, boolean, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const SupplierFilterSchema = object().shape({
category_id: string().nullable(),
flag: boolean().nullable(),
export const SupplierFilterSchema = Yup.object().shape({
category: Yup.object({
value: Yup.string().required(),
label: Yup.string().required(),
}).nullable(),
flag: Yup.boolean().nullable(),
});
export type SupplierFilterType = {
category_id: string | null;
category?: OptionType<string>;
flag: boolean | null;
};
@@ -31,6 +31,9 @@ export const SupplierFormSchema = Yup.object({
npwp: Yup.string()
.matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
.required('Nomor NPWP wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'),
@@ -122,6 +122,7 @@ const SupplierForm = ({
email: initialValues?.email ?? '',
address: initialValues?.address ?? '',
npwp: initialValues?.npwp ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '',
due_date: initialValues?.due_date ?? 1,
};
@@ -149,6 +150,7 @@ const SupplierForm = ({
email: values.email,
address: values.address,
npwp: values.npwp,
bank_name: values.bank_name,
account_number: values.account_number,
due_date: parseInt(values.due_date.toString()),
};
@@ -368,6 +370,22 @@ const SupplierForm = ({
errorMessage={formik.errors.npwp}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank supplier'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -20,8 +20,6 @@ import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
};
const UomsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -114,22 +109,16 @@ const UomsTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: searchValue,
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'uom-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('uoms-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -146,8 +135,7 @@ const UomsTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,13 +1,6 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -31,7 +24,6 @@ import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
WarehouseFilterSchema,
WarehouseFilterType,
@@ -120,9 +112,6 @@ const RowOptionsMenu = ({
};
const WarehousesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -141,6 +130,8 @@ const WarehousesTable = () => {
areaFilter: 'area_id',
activeProjectFlockFilter: 'active_project_flock',
},
persist: true,
storeName: 'warehouses-table',
});
// ===== FILTER MODAL STATE =====
@@ -149,27 +140,36 @@ const WarehousesTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<WarehouseFilterType>({
initialValues: {
area_id: null,
active_project_flock: false,
area_id: tableFilterState.areaFilter || null,
active_project_flock:
tableFilterState.activeProjectFlockFilter === 'true',
},
validationSchema: WarehouseFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '');
updateFilter('areaFilter', values.area_id || '', true);
updateFilter(
'activeProjectFlockFilter',
values.active_project_flock === true ? 'true' : ''
values.active_project_flock === true ? 'true' : '',
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('activeProjectFlockFilter', '');
formik.setFieldValue('active_project_flock', false);
},
});
const { setFieldValue } = formik;
const formikResetHandler = () => {
updateFilter('areaFilter', '', true);
updateFilter('activeProjectFlockFilter', '', true);
formik.resetForm({
values: {
area_id: null,
active_project_flock: false,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS =====
const {
@@ -243,26 +243,6 @@ const WarehousesTable = () => {
formik.validateForm();
};
useEffect(() => {
if (filterModal.open) {
const activeProjectFlockValue =
tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang)
setFieldValue('active_project_flock', activeProjectFlockValue);
}
}, [
filterModal.open,
tableFilterState.activeProjectFlockFilter,
setFieldValue,
]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('warehouses-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -281,8 +261,7 @@ const WarehousesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -507,7 +486,7 @@ const WarehousesTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
@@ -538,10 +517,7 @@ const WarehousesTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
onClick={formikResetHandler}
>
Reset Filter
</Button>
@@ -2,16 +2,17 @@ import Alert from '@/components/Alert';
import Button from '@/components/Button';
import Card from '@/components/Card';
import RequirePermission from '@/components/helper/RequirePermission';
import DateInput from '@/components/input/DateInput';
import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin';
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
import { BaseApproval } from '@/types/api/api-general';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
const ChickinLogsView = ({
initialValues,
@@ -23,6 +24,9 @@ const ChickinLogsView = ({
rawDataApprovals: BaseApproval[];
}) => {
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const [editingChickinId, setEditingChickinId] = useState<number | null>(null);
const [editDate, setEditDate] = useState('');
const [isEditLoading, setIsEditLoading] = useState(false);
const { openChickinApproveModal, openChickinDeleteModal } = useChickinStore();
const handleClickApprove = () => {
@@ -44,6 +48,23 @@ const ChickinLogsView = ({
});
};
const handleSaveChickinDate = async () => {
setIsEditLoading(true);
const res = await ChickinApi.updateChickinDate(
initialValues.id as number,
formatDate(editDate, 'YYYY-MM-DD')
);
setIsEditLoading(false);
if (isResponseSuccess(res)) {
toast.success(res?.message as string);
setEditingChickinId(null);
afterSubmit && afterSubmit();
}
if (isResponseError(res)) {
toast.error(res?.message as string);
}
};
const handleDeleteChickin = (chickinId: number) => {
openChickinDeleteModal(chickinId, async () => {
const deleteRes = await ChickinApi.delete(chickinId);
@@ -133,9 +154,54 @@ const ChickinLogsView = ({
<Icon icon={'mdi:calendar'} width={14} height={14} />{' '}
<span>Tanggal Chick In</span>
</div>
<div className='text-end text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</div>
{editingChickinId === chickin.id ? (
<div className='flex flex-col gap-2 items-end w-1/2'>
<DateInput
name='edit_chick_in_date'
value={editDate}
isNestedModal
onChange={(e) => setEditDate(e.target.value)}
/>
<div className='flex flex-row gap-1'>
<Button
color='none'
className='btn-xs btn-ghost text-gray-500'
onClick={() => setEditingChickinId(null)}
disabled={isEditLoading}
>
Batal
</Button>
<Button
color='success'
className='btn-xs text-base-100'
onClick={handleSaveChickinDate}
isLoading={isEditLoading}
disabled={!editDate || isEditLoading}
>
Simpan
</Button>
</div>
</div>
) : (
<div className='flex flex-row gap-2 items-center'>
<span className='text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</span>
<button
className='btn btn-ghost btn-xs p-0 text-gray-400 hover:text-primary'
onClick={() => {
setEditingChickinId(chickin.id);
setEditDate(chickin.chick_in_date);
}}
>
<Icon
icon='mdi:pencil-outline'
width={13}
height={13}
/>
</button>
</div>
)}
</div>
{/* Kandang */}
@@ -11,7 +11,6 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import Dropdown from '@/components/Dropdown';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
@@ -23,7 +22,6 @@ import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useRouter, usePathname } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { useUiStore } from '@/stores/ui/ui.store';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import { useFormik } from 'formik';
@@ -45,6 +43,7 @@ import {
import Modal from '@/components/Modal';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import ButtonFilter from '@/components/helper/ButtonFilter';
import NumberInput from '@/components/input/NumberInput';
const RowOptionsMenu = ({
props,
@@ -148,7 +147,6 @@ const RowOptionsMenu = ({
};
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
@@ -174,6 +172,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
kandang_id: '',
category: '',
period: '',
area_name: '',
location_name: '',
kandang_name: '',
},
paramMap: {
page: 'page',
@@ -185,7 +186,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
category: 'category',
period: 'period',
},
excludeKeysFromUrl: ['area_name', 'location_name', 'kandang_name'],
persist: true,
storeName: 'project-flock-table',
});
const router = useRouter();
// ===== State =====
@@ -206,8 +211,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const {
isChickinApproveModalOpen,
isChickinApproveLoading,
@@ -257,6 +261,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', values.kandang_id || '');
updateFilter('category', values.category || '');
updateFilter('period', values.period || '');
updateFilter(
'area_name',
areaValue?.label ? String(areaValue.label) : ''
);
updateFilter(
'location_name',
locationValue?.label ? String(locationValue.label) : ''
);
updateFilter(
'kandang_name',
kandangValue?.label ? String(kandangValue.label) : ''
);
filterModal.closeModal();
setSubmitting(false);
},
@@ -266,6 +282,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', '');
updateFilter('category', '');
updateFilter('period', '');
updateFilter('area_name', '');
updateFilter('location_name', '');
updateFilter('kandang_name', '');
setFilterAreaId(undefined);
setFilterLocationId(undefined);
filterModal.closeModal();
@@ -307,40 +326,55 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[]
);
const periodOptions = useMemo(
() => [
{ value: '1', label: 'Periode 1' },
{ value: '2', label: 'Periode 2' },
],
[]
);
// ===== FILTER HELPERS =====
const areaValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
const found = areaOptions.find(
(opt) => String(opt.value) === formik.values.area_id
);
}, [formik.values.area_id, areaOptions]);
if (found) return found;
if (tableFilterState.area_name) {
return {
value: formik.values.area_id,
label: tableFilterState.area_name,
};
}
return null;
}, [formik.values.area_id, areaOptions, tableFilterState.area_name]);
const locationValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
const found = locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
);
}, [formik.values.location_id, locationOptions]);
if (found) return found;
if (tableFilterState.location_name) {
return {
value: formik.values.location_id,
label: tableFilterState.location_name,
};
}
return null;
}, [
formik.values.location_id,
locationOptions,
tableFilterState.location_name,
]);
const kandangValue = useMemo(() => {
if (!formik.values.kandang_id) return null;
return (
kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
const found = kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
);
}, [formik.values.kandang_id, kandangOptions]);
if (found) return found;
if (tableFilterState.kandang_name) {
return {
value: formik.values.kandang_id,
label: tableFilterState.kandang_name,
};
}
return null;
}, [formik.values.kandang_id, kandangOptions, tableFilterState.kandang_name]);
const categoryValue = useMemo(() => {
if (!formik.values.category) return null;
@@ -350,13 +384,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
}, [formik.values.category, categoryOptions]);
const periodValue = useMemo(() => {
if (!formik.values.period) return null;
return (
periodOptions.find((opt) => opt.value === formik.values.period) || null
);
}, [formik.values.period, periodOptions]);
// ===== FILTER DEPENDENCY HANDLERS =====
const handleFilterAreaChange = (area: OptionType | null) => {
const areaId = area?.value ? String(area.value) : undefined;
@@ -425,18 +452,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setIsDeleteLoading(false);
setRowSelection({});
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('project-flock-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
updateFilter('search', e.target.value, true);
};
const confirmApprovalHandler = async (
notes: string,
approvalAction: 'APPROVED' | 'REJECTED'
@@ -554,6 +574,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
price: budget.price,
total_price: budget.qty * budget.price,
})) || [],
periode: createdProjectFlock.period ?? '-',
} as ProjectFlockFormValues;
}, [createdProjectFlock]);
@@ -776,14 +797,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[]
);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
toast.error('Not implemented yet!');
setIsLoadingExportingToExcel(false);
};
const bulkApproveClickHandler = () => {
setApprovalAction('APPROVED');
confirmModal.openModal();
@@ -972,55 +985,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
excludeFields={[
'page',
'pageSize',
'search',
'area_name',
'location_name',
'kandang_name',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcelHandler}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
</Dropdown>
</div>
</div>
@@ -1349,18 +1324,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
isClearable={true}
/>
<SelectInputRadio
<NumberInput
label='Periode'
placeholder='Pilih Periode'
options={periodOptions}
value={periodValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue('period', val?.value || null);
}
}}
name='period'
placeholder='Masukkan Periode'
value={formik.values.period ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={{ wrapper: 'w-full' }}
isClearable
/>
</div>
@@ -26,6 +26,7 @@ type ProjectFlockFormSchemaType = {
label: string;
} | null;
location_id: number;
periode: number | string;
kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[];
};
@@ -109,6 +110,12 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'),
// Period
periode: Yup.number()
.typeError('Periode harus berupa angka!')
.min(1, 'Periode minimal 1!')
.required('Periode wajib diisi!'),
kandang_ids: Yup.array()
.of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!')
@@ -152,6 +152,10 @@ export const ProjectFlockFormConfirmationTable = ({
label: 'Standar Produksi',
value: projectFlockForm?.production_standard?.label ?? '-',
},
{
label: 'Periode',
value: projectFlockForm?.periode ?? '-',
},
{
label: 'Informasi Kandang',
value: '',
@@ -261,7 +265,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingFlocks,
options: optionsFlock,
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
} = useSelect(FlockApi.basePath, 'id', 'name', 'search', {
project_category: selectedCategory,
location_id: selectedLocation,
area_id: selectedArea,
@@ -279,7 +283,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingLocations,
setInputValue: setInputValueLocation,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
area_id:
selectedArea != ''
? selectedArea
@@ -291,7 +295,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', {
project_category: selectedCategory,
});
@@ -307,7 +311,7 @@ const ProjectFlockForm = ({
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
`${selectedFlock?.toString()}/periods`,
selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined,
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
);
@@ -529,6 +533,7 @@ const ProjectFlockForm = ({
kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id
) as number[],
periode: initialValues?.period ?? '',
project_budgets: initialValues?.project_budgets?.map((budget) => {
return {
nonstock: {
@@ -568,6 +573,7 @@ const ProjectFlockForm = ({
category: values.category as string,
production_standard_id: values.production_standard_id as number,
location_id: values.location_id as number,
periode: parseInt(values.periode as unknown as string),
kandang_ids: values.kandang_ids as number[],
project_budgets: values.project_budgets.flatMap((budget) => {
return {
@@ -793,6 +799,7 @@ const ProjectFlockForm = ({
formik.values.kandang_ids?.includes(kandang.id)
)?.period
: undefined;
const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
@@ -1022,12 +1029,18 @@ const ProjectFlockForm = ({
isDisabled={formType != 'add'}
/>
<NumberInput
name='period'
name='periode'
label='Periode'
disabled
readOnly
placeholder='Periode Flock'
value={selectedLocation ? inputPeriod : ''}
value={formik.values.periode}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
allowNegative={false}
decimalScale={0}
isError={
formik.touched.periode && Boolean(formik.errors.periode)
}
errorMessage={formik.errors.periode as string}
/>
</div>
@@ -1,12 +1,6 @@
'use client';
import React, {
useCallback,
useState,
useMemo,
useEffect,
useRef,
} from 'react';
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
@@ -18,6 +12,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { OptionType } from '@/components/input/SelectInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -39,13 +34,11 @@ import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast';
import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import { useUiStore } from '@/stores/ui/ui.store';
import { usePathname } from 'next/navigation';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
@@ -75,6 +68,26 @@ const getStatusBadgeColor = (status: string): Color => {
return statusBadgeColorMap[normalizedStatus] || 'neutral';
};
const isRecordingApproved = (recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
};
// ===== FILTER HELPERS =====
const recordingApprovalStatusOptions: OptionType<string>[] = [
{ value: 'CREATED', label: 'Pengajuan' },
{ value: 'UPDATED', label: 'Diperbarui' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const projectFlockCategoryOptions: OptionType<string>[] = [
{ value: 'GROWING', label: 'Growing' },
{ value: 'LAYING', label: 'Laying' },
];
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
@@ -266,80 +279,111 @@ const RowOptionsMenu = ({
};
const RecordingTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<{
search: string;
areaFilter: OptionType<number> | null;
locationFilter: OptionType<number> | null;
projectFlockFilter: OptionType<number> | null;
kandangFilter: OptionType<number> | null;
projectFlockKandangFilter: number | null;
approvalStatusFilter: OptionType<string> | null;
projectFlockCategoryFilter: OptionType<string> | null;
}>({
initial: {
search: '',
areaFilter: '',
locationFilter: '',
kandangFilter: '',
projectFlockKandangFilter: '',
areaFilter: null,
locationFilter: null,
projectFlockFilter: null,
kandangFilter: null,
projectFlockKandangFilter: null,
approvalStatusFilter: null,
projectFlockCategoryFilter: null,
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
areaFilter: 'area_id',
locationFilter: 'location_id',
projectFlockFilter: 'project_flock_id',
kandangFilter: 'kandang_id',
projectFlockKandangFilter: 'project_flock_kandang_id',
approvalStatusFilter: 'approval_status',
projectFlockCategoryFilter: 'project_flock_category',
},
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
persist: true,
storeName: 'recording-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FILTER STATE =====
const [filterArea, setFilterArea] = useState<OptionType | null>(null);
const [filterLocation, setFilterLocation] = useState<OptionType | null>(null);
const [filterProjectFlock, setFilterProjectFlock] =
useState<OptionType | null>(null);
const [filterKandang, setFilterKandang] = useState<OptionType | null>(null);
const [, setFilterProjectFlockKandangId] = useState<number | undefined>(
undefined
);
const [filterLocationAreaId, setFilterLocationAreaId] = useState<string>('');
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
// ===== FORMIK SETUP =====
const formik = useFormik<RecordingFilterType>({
initialValues: {
area_id: null,
location_id: null,
kandang_id: null,
project_flock_kandang_id: null,
area_id: tableFilterState.areaFilter,
location_id: tableFilterState.locationFilter,
project_flock_id: tableFilterState.projectFlockFilter,
kandang_id: tableFilterState.kandangFilter,
project_flock_kandang_id: tableFilterState.projectFlockKandangFilter,
approval_status: tableFilterState.approvalStatusFilter,
project_flock_category: tableFilterState.projectFlockCategoryFilter,
},
validationSchema: RecordingFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '');
updateFilter('locationFilter', values.location_id || '');
updateFilter('kandangFilter', values.kandang_id || '');
updateFilter('areaFilter', values.area_id, true);
updateFilter('locationFilter', values.location_id, true);
updateFilter('projectFlockFilter', values.project_flock_id, true);
updateFilter('kandangFilter', values.kandang_id, true);
updateFilter(
'projectFlockKandangFilter',
values.project_flock_kandang_id || ''
values.project_flock_kandang_id,
true
);
updateFilter('approvalStatusFilter', values.approval_status, true);
updateFilter(
'projectFlockCategoryFilter',
values.project_flock_category,
true
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('locationFilter', '');
updateFilter('kandangFilter', '');
updateFilter('projectFlockKandangFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('areaFilter', null, true);
updateFilter('locationFilter', null, true);
updateFilter('projectFlockFilter', null, true);
updateFilter('kandangFilter', null, true);
updateFilter('projectFlockKandangFilter', null, true);
updateFilter('approvalStatusFilter', null, true);
updateFilter('projectFlockCategoryFilter', null, true);
formik.resetForm({
values: {
area_id: null,
location_id: null,
project_flock_id: null,
kandang_id: null,
project_flock_kandang_id: null,
approval_status: null,
project_flock_category: null,
},
});
filterModal.closeModal();
};
const { project_flock_id, kandang_id } = formik.values;
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
@@ -355,10 +399,14 @@ const RecordingTable = () => {
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const singleDeleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const exportProgressInputModal = useModal();
const {
data: recordings,
@@ -370,13 +418,6 @@ const RecordingTable = () => {
);
// ===== LOCATION, AREA, KANDANG OPTIONS =====
const locationParams = useMemo(() => {
if (filterLocationAreaId) {
return { area_id: filterLocationAreaId };
}
return undefined;
}, [filterLocationAreaId]);
const {
setInputValue: setLocationInputValue,
options: locationOptions,
@@ -387,7 +428,9 @@ const RecordingTable = () => {
'id',
'name',
'search',
locationParams
{
area_id: String(formik.values.area_id?.value),
}
);
const {
@@ -402,13 +445,6 @@ const RecordingTable = () => {
'search'
);
const projectFlockParams = useMemo(() => {
if (filterProjectFlockLocationId) {
return { location_id: filterProjectFlockLocationId };
}
return undefined;
}, [filterProjectFlockLocationId]);
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
@@ -420,34 +456,41 @@ const RecordingTable = () => {
'id',
'flock_name',
'search',
projectFlockParams
{
location_id: String(formik.values.location_id?.value),
}
);
const kandangOptions = useMemo(() => {
if (!filterProjectFlock || !projectFlocksRawData) return [];
if (!project_flock_id || !projectFlocksRawData) return [];
if (!isResponseSuccess(projectFlocksRawData)) return [];
const data = projectFlocksRawData.data as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === filterProjectFlock.value
const selectedProjectFlockData = data.find((pf) =>
pf.id === formik.values.project_flock_id?.value
? Number(formik.values.project_flock_id.value)
: 0
);
if (!selectedProjectFlockData?.kandangs) return [];
return selectedProjectFlockData.kandangs.map((k) => ({
value: k.id,
label: k.name || '',
}));
}, [filterProjectFlock, projectFlocksRawData]);
}, [project_flock_id, projectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => {
if (!filterProjectFlock || !filterKandang) return null;
if (!project_flock_id?.value || !kandang_id?.value) return null;
const params = new URLSearchParams({
project_flock_id: filterProjectFlock.value.toString(),
kandang_id: filterKandang.value.toString(),
project_flock_id: project_flock_id.value.toString(),
kandang_id: kandang_id.value.toString(),
});
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [filterProjectFlock, filterKandang]);
}, [project_flock_id, kandang_id]);
const { data: projectFlockKandangLookupData } = useSWR(
projectFlockKandangLookupUrl,
@@ -469,118 +512,45 @@ const RecordingTable = () => {
? projectFlockKandangLookupData.data
: undefined;
const formikRef = useRef(formik);
useEffect(() => {
formikRef.current = formik;
});
useEffect(() => {
if (projectFlockKandangLookup?.id) {
const pfkId = String(projectFlockKandangLookup.id);
setFilterProjectFlockKandangId(projectFlockKandangLookup.id);
formikRef.current.setFieldValue('project_flock_kandang_id', pfkId);
formik.setFieldValue('project_flock_kandang_id', pfkId);
} else {
setFilterProjectFlockKandangId(undefined);
formikRef.current.setFieldValue('project_flock_kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
}
}, [projectFlockKandangLookup]);
// ===== FILTER HANDLERS =====
const handleFilterAreaChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const area = val as OptionType | null;
const areaId = area?.value ? String(area.value) : null;
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('area_id', val);
formik.setFieldValue('location_id', null);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
formik.setFieldValue('area_id', areaId);
formik.setFieldValue('location_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
const handleFilterLocationChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('location_id', val);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
setFilterArea(area);
setFilterLocation(null);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterLocationAreaId(areaId || '');
setFilterProjectFlockLocationId('');
},
[formik]
);
const handleFilterProjectFlockChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('project_flock_id', val);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location?.value ? String(location.value) : null;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterLocation(location);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterProjectFlockLocationId(locationId || '');
},
[formik]
);
const handleFilterProjectFlockChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const projectFlock = val as OptionType | null;
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterProjectFlock(projectFlock);
setFilterKandang(null);
},
[formik]
);
const handleFilterKandangChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const kandang = val as OptionType | null;
const kandangId = kandang?.value ? String(kandang.value) : null;
formik.setFieldValue('kandang_id', kandangId);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterKandang(kandang);
},
[formik]
);
// ===== FILTER HELPERS =====
const areaIdValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const projectFlockIdValue = useMemo(() => {
if (!filterProjectFlock) return null;
return filterProjectFlock;
}, [filterProjectFlock]);
const kandangIdValue = useMemo(() => {
if (!formik.values.kandang_id) return null;
return (
kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
);
}, [formik.values.kandang_id, kandangOptions]);
const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang_id', val);
formik.setFieldValue('project_flock_kandang_id', null);
};
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -588,25 +558,9 @@ const RecordingTable = () => {
formik.validateForm();
};
const isRecordingApproved = useCallback((recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
}, []);
useEffect(() => {
setTableState('recording-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value, true);
};
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
@@ -698,6 +652,60 @@ const RecordingTable = () => {
setIsLoadingExportingToExcel(false);
};
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressStartDate(e.target.value);
},
[]
);
const exportProgressEndDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressEndDate(e.target.value);
},
[]
);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await RecordingApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) {
const newSelection: Record<string, boolean> = {};
@@ -858,7 +866,8 @@ const RecordingTable = () => {
<>
<span>
{props.row.original.day} (Minggu ke-
{props.row.original.project_flock.production_standart.week})
{props.row.original.week} hari ke-
{props.row.original.excess_days})
</span>
</>
);
@@ -1104,7 +1113,7 @@ const RecordingTable = () => {
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
? `${value.toFixed(2)} butir`
: '-'}
</div>
);
@@ -1120,7 +1129,7 @@ const RecordingTable = () => {
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
? `${value.toFixed(2)} btr`
: '-'}
</div>
);
@@ -1368,6 +1377,16 @@ const RecordingTable = () => {
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
</div>
@@ -1446,13 +1465,13 @@ const RecordingTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={areaIdValue}
value={formik.values.area_id}
onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions}
@@ -1465,13 +1484,13 @@ const RecordingTable = () => {
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationIdValue}
value={formik.values.location_id}
onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
onMenuScrollToBottom={loadMoreLocations}
isDisabled={!filterArea}
isDisabled={!formik.values.area_id?.value}
className={{ wrapper: 'w-full' }}
/>
@@ -1479,13 +1498,13 @@ const RecordingTable = () => {
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={projectFlockIdValue}
value={formik.values.project_flock_id}
onChange={handleFilterProjectFlockChange}
onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlocks}
isClearable
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!filterLocation}
isDisabled={!formik.values.location_id?.value}
className={{ wrapper: 'w-full' }}
/>
@@ -1493,11 +1512,35 @@ const RecordingTable = () => {
label='Kandang'
placeholder='Pilih Kandang'
options={kandangOptions}
value={kandangIdValue}
value={formik.values.kandang_id}
onChange={handleFilterKandangChange}
isLoading={!filterProjectFlock}
isLoading={!formik.values.project_flock_id?.value}
isClearable
isDisabled={!formik.values.project_flock_id?.value}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={projectFlockCategoryOptions}
value={formik.values.project_flock_category}
onChange={(val) => {
formik.setFieldValue('project_flock_category', val);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status Approval'
placeholder='Pilih Status Approval'
options={recordingApprovalStatusOptions}
value={formik.values.approval_status}
onChange={(val) => {
formik.setFieldValue('approval_status', val);
}}
isClearable
isDisabled={!filterProjectFlock}
className={{ wrapper: 'w-full' }}
/>
</div>
@@ -1505,30 +1548,16 @@ const RecordingTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='button'
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
setFilterArea(null);
setFilterLocation(null);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterLocationAreaId('');
setFilterProjectFlockLocationId('');
filterModal.closeModal();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={
!formik.isValid ||
formik.isSubmitting ||
!formik.values.kandang_id
}
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
@@ -1551,6 +1580,76 @@ const RecordingTable = () => {
}}
/>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
@@ -1,15 +1,40 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
export const RecordingFilterSchema = object().shape({
area_id: string().nullable(),
location_id: string().nullable(),
kandang_id: string().nullable(),
project_flock_kandang_id: string().nullable(),
export const RecordingFilterSchema = Yup.object().shape({
area_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
location_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
kandang_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_kandang_id: Yup.number().nullable(),
approval_status: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
});
export type RecordingFilterType = {
area_id: string | null;
location_id: string | null;
kandang_id: string | null;
project_flock_kandang_id: string | null;
area_id: OptionType<number> | null;
location_id: OptionType<number> | null;
project_flock_id: OptionType<number> | null;
kandang_id: OptionType<number> | null;
project_flock_kandang_id: number | null;
approval_status: OptionType<string> | null;
project_flock_category: OptionType<string> | null;
};
@@ -4,7 +4,9 @@ import {
CreateGrowingRecordingPayload,
CreateLayingRecordingPayload,
CreateEggPayload,
RecordingStock,
} from '@/types/api/production/recording';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type RecordingGrowingFormSchemaType = {
record_date: string;
@@ -29,11 +31,19 @@ type RecordingGrowingFormSchemaType = {
} | null;
project_flock_kandang_id: number;
stocks: {
product_warehouse_id: number;
product_warehouse_id:
| {
value: number;
label: string;
}
| undefined;
qty: number | string;
}[];
depletions: {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string;
}[];
@@ -41,34 +51,48 @@ type RecordingGrowingFormSchemaType = {
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
qty?: number | string;
weight?: number | string;
}[];
};
export type StockSchema = {
product_warehouse_id: number;
product_warehouse_id: {
value: number;
label: string;
};
qty: number | string;
};
export type DepletionSchema = {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string;
};
export type EggSchema = {
product_warehouse_id?: number;
product_warehouse_id?: {
value: number;
label: string;
} | null;
qty?: number | string;
weight?: number | string;
};
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
product_warehouse_id: Yup.number()
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk harus berupa angka!'),
.typeError('Produk wajib diisi!'),
qty: Yup.number()
.required('Jumlah penggunaan wajib diisi!')
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
@@ -76,9 +100,12 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
});
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number()
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.optional()
.typeError('Depletions harus berupa angka!'),
.nullable(),
source_product_warehouse_id: Yup.number()
.optional()
.typeError('Gudang sumber harus berupa angka!'),
@@ -88,9 +115,12 @@ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
});
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.number()
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.optional()
.typeError('Kondisi telur harus berupa angka!'),
.nullable(),
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
});
@@ -248,14 +278,18 @@ export const getRecordingGrowingFormInitialValues = (
initialValues?.project_flock?.project_flock_kandang_id ??
0,
stocks: initialValues?.stocks?.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
product_warehouse_id: {
value: stock.product_warehouse_id,
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty:
(stock as { qty?: number; usage_amount?: number }).qty ||
(stock as { qty?: number; usage_amount?: number }).usage_amount ||
(stock as RecordingStock).qty ||
((stock as RecordingStock).usage_amount || 0) +
((stock as RecordingStock).pending_qty || 0) ||
'',
})) ?? [
{
product_warehouse_id: 0,
product_warehouse_id: undefined,
qty: '',
},
],
@@ -263,13 +297,16 @@ export const getRecordingGrowingFormInitialValues = (
(
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
) => ({
product_warehouse_id: depletion.product_warehouse_id,
product_warehouse_id: {
value: Number(depletion.product_warehouse_id ?? 0),
label: getProductWarehouseOptionLabel(depletion.product_warehouse),
},
source_product_warehouse_id: depletion.source_product_warehouse_id,
qty: depletion.qty,
})
) ?? [
{
product_warehouse_id: 0,
product_warehouse_id: undefined,
qty: '',
},
],
@@ -281,12 +318,15 @@ export const getRecordingLayingFormInitialValues = (
...getRecordingGrowingFormInitialValues(initialValues),
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: egg.product_warehouse_id,
product_warehouse_id: {
value: Number(egg.product_warehouse_id ?? 0),
label: getProductWarehouseOptionLabel(egg.product_warehouse),
},
qty: egg.qty,
weight: egg.weight,
})) ?? [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
@@ -31,12 +31,14 @@ import {
RecordingApi,
ProjectFlockApi,
} from '@/services/api/production';
import { ProductionStandardApi } from '@/services/api/master-data';
import { ProductionStandardApi, ProductApi } from '@/services/api/master-data';
import {
ProductionStandard,
StandardDetails,
} from '@/types/api/master-data/production-standard';
import { Product } from '@/types/api/master-data/product';
import { LocationApi } from '@/services/api/master-data';
import { SystemSettingsApi } from '@/services/api/system-settings';
import { ProductWarehouseApi } from '@/services/api/inventory';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
@@ -499,6 +501,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
type,
]);
// ===== MIGRATION MODE =====
const { data: systemSettingsResponse } = useSWR(
SystemSettingsApi.basePath,
SystemSettingsApi.getAllFetcher
);
const isMigrationMode = useMemo(() => {
if (!isResponseSuccess(systemSettingsResponse)) return false;
const setting = systemSettingsResponse.data.find(
(s) => s.key === 'allow_negative_pakan_ovk'
);
return setting?.value === 'true';
}, [systemSettingsResponse]);
// ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => {
@@ -506,7 +522,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
...(depletion.source_product_warehouse_id && {
source_product_warehouse_id:
depletion.source_product_warehouse_id,
@@ -517,9 +533,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const stocks = recordingRestriction.canEditStock
? (values.stocks ?? [])
.filter((s) => s.product_warehouse_id && s.qty)
.filter((s) => s.product_warehouse_id?.value && s.qty)
.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
// In migration mode, product_warehouse_id field holds product.id;
// send it as product_id so the backend auto-creates the warehouse entry.
...(isMigrationMode
? { product_id: stock.product_warehouse_id?.value }
: { product_warehouse_id: stock.product_warehouse_id?.value }),
qty: Number(stock.qty) || 0,
}))
: [];
@@ -531,15 +551,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(depletions.length > 0 && { depletions }),
};
},
[recordingRestriction.canEditStock, recordingRestriction.canEditDepletion]
[
isMigrationMode,
recordingRestriction.canEditStock,
recordingRestriction.canEditDepletion,
]
);
const createLayingPayload = useCallback(
(values: RecordingLayingFormValues) => {
const depletions = values.depletions
?.filter((d) => d.product_warehouse_id && d.qty)
?.filter((d) => d.product_warehouse_id?.value && d.qty)
.map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id!,
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
...(depletion.source_product_warehouse_id && {
source_product_warehouse_id: depletion.source_product_warehouse_id,
}),
@@ -549,7 +573,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const eggs = values.eggs
?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
.map((egg) => ({
product_warehouse_id: egg.product_warehouse_id!,
product_warehouse_id: egg.product_warehouse_id?.value ?? 0,
qty: Number(egg.qty) || 0,
weight:
typeof egg.weight === 'number'
@@ -559,9 +583,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const stocks = recordingRestriction.canEditStock
? values.stocks
.filter((s) => s.product_warehouse_id && s.qty)
.filter((s) => s.product_warehouse_id?.value && s.qty)
.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
...(isMigrationMode
? { product_id: stock.product_warehouse_id?.value }
: { product_warehouse_id: stock.product_warehouse_id?.value }),
qty: Number(stock.qty) || 0,
}))
: [];
@@ -574,7 +600,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(eggs && eggs.length > 0 && { eggs }),
};
},
[recordingRestriction.canEditStock]
[isMigrationMode, recordingRestriction.canEditStock]
);
const isRecordingEditable = useCallback((recording?: Recording) => {
@@ -603,19 +629,44 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return true;
}, []);
// When migration mode ON: fetch all master PAKAN/OVK products (no warehouse entry needed).
// When migration mode OFF: fetch from product-warehouses as usual.
const {
setInputValue: setStockProductInputValue,
rawData: stockProducts,
isLoadingOptions: isLoadingStockProducts,
loadMore: loadMoreStockProducts,
rawData: stockProductsPW,
isLoadingOptions: isLoadingStockProductsPW,
loadMore: loadMoreStockProductsPW,
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', 'search', {
flags: 'PAKAN,OVK',
limit: '100',
available_only: 'true',
available_only: 'false',
location_id: stockProductsLocationId,
...(selectedKandangId ? { kandang_id: selectedKandangId.toString() } : {}),
});
const {
setInputValue: setStockMasterInputValue,
rawData: stockProductsMaster,
isLoadingOptions: isLoadingStockProductsMaster,
loadMore: loadMoreStockProductsMaster,
} = useSelect(
isMigrationMode ? ProductApi.basePath : null,
'id',
'name',
'search',
{ flags: 'PAKAN,OVK', limit: '100' }
);
const isLoadingStockProducts = isMigrationMode
? isLoadingStockProductsMaster
: isLoadingStockProductsPW;
const loadMoreStockProducts = isMigrationMode
? loadMoreStockProductsMaster
: loadMoreStockProductsPW;
const setStockInputValue = isMigrationMode
? setStockMasterInputValue
: setStockProductInputValue;
const {
rawData: depletionProductsData,
isLoadingOptions: isLoadingDepletionProducts,
@@ -999,9 +1050,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
useEffect(() => {
const items: Array<ProductWarehouse | null | undefined> = [];
if (isResponseSuccess(stockProducts)) {
if (!isMigrationMode && isResponseSuccess(stockProductsPW)) {
items.push(
...((stockProducts.data as unknown as ProductWarehouse[]) ?? [])
...((stockProductsPW.data as unknown as ProductWarehouse[]) ?? [])
);
}
@@ -1035,7 +1086,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
mergeKnownProductWarehouses(items);
}, [
stockProducts,
isMigrationMode,
stockProductsPW,
depletionProductsData,
eggProductsData,
initialValues,
@@ -1066,9 +1118,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
);
const unifiedStockProducts = useMemo(() => {
const options = isResponseSuccess(stockProducts)
if (isMigrationMode) {
// In migration mode, show all master PAKAN/OVK products (no warehouse context).
// value = product.id; submission will send product_id to the backend.
const options: OptionType[] = isResponseSuccess(stockProductsMaster)
? (stockProductsMaster.data as unknown as Product[])
.map((p) => ({ value: p.id, label: p.name }))
.sort((a, b) => a.label.localeCompare(b.label))
: [];
return options;
}
const options = isResponseSuccess(stockProductsPW)
? buildProductWarehouseOptions(
stockProducts.data as unknown as ProductWarehouse[]
stockProductsPW.data as unknown as ProductWarehouse[]
)
: [];
@@ -1085,7 +1148,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return options;
}, [
stockProducts,
isMigrationMode,
stockProductsMaster,
stockProductsPW,
buildProductWarehouseOptions,
initialValues,
type,
@@ -1204,6 +1269,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
};
}
// In migration mode (edit), the dropdown options use product.id as their value,
// but the API returns product_warehouse_id (PW entity ID). Remap so the dropdown
// can match the correct option. The product ID is available on the nested
// product_warehouse object returned by the API.
if (isMigrationMode && type === 'edit' && initialValues?.stocks?.length) {
baseValues.stocks = initialValues.stocks.map((stock) => ({
product_warehouse_id: {
value: Number(
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
),
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty: stock.usage_amount ?? '',
}));
}
if (!recordingRestriction.canEditStock) {
baseValues.stocks = [];
}
@@ -1224,6 +1305,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
selectedKandang,
recordingRestriction.canEditStock,
recordingRestriction.canEditDepletion,
isMigrationMode,
]);
const formik = useFormik<
@@ -1335,6 +1417,35 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
},
});
// SWR timing fix: formik initializes before system-settings load, so isMigrationMode
// starts false. When it flips true, formikInitialValues recomputes but enableReinitialize
// is false, so formik won't pick it up. Push the corrected stock values once, and only
// once — the ref prevents re-firing if something causes isMigrationMode to re-evaluate.
const migrationEditMappingApplied = useRef(false);
useEffect(() => {
if (
type !== 'edit' ||
!isMigrationMode ||
!initialValues?.stocks?.length ||
migrationEditMappingApplied.current
)
return;
migrationEditMappingApplied.current = true;
formik.setFieldValue(
'stocks',
initialValues.stocks.map((stock) => ({
product_warehouse_id: {
value: Number(
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
),
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty: stock.usage_amount ?? '',
}))
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMigrationMode]);
// ===== HELPER FUNCTIONS =====
const { setFieldValue } = formik;
@@ -1351,7 +1462,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(stockIdx: number) => {
if ((type as 'add' | 'edit' | 'detail') === 'detail') return null;
const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id) return null;
if (!stock || !stock.product_warehouse_id?.value) return null;
return null;
},
[formik.values.stocks, type]
@@ -1361,7 +1472,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(productWarehouseId: number) => {
if ((type === 'edit' || type === 'detail') && initialValues?.stocks) {
const existingStock = initialValues.stocks.find(
(s) => s.product_warehouse_id === productWarehouseId
(s) => Number(s.product_warehouse_id) === Number(productWarehouseId)
) as RecordingStock | undefined;
if (existingStock) {
return {
@@ -1381,21 +1492,25 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const getStockUsageAdornment = useCallback(
(stockIdx: number) => {
const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id) return null;
if (!stock || !stock.product_warehouse_id?.value) return null;
const isDetail = (type as 'add' | 'edit' | 'detail') === 'detail';
const availableStock = getAvailableStock(stock.product_warehouse_id);
const availableStock = getAvailableStock(
stock.product_warehouse_id.value
);
const requestedUsage = Number(stock.qty) || 0;
const remainingStock = availableStock - requestedUsage;
const { pendingQty } = getStockPendingInfo(stock.product_warehouse_id);
const { pendingQty } = getStockPendingInfo(
stock.product_warehouse_id.value
);
if (isDetail) {
if (pendingQty > 0) {
return (
<span className='text-sm text-gray-600 whitespace-nowrap'>
(tersedia: {formatNumber(requestedUsage)} | pending:{' '}
(tersedia: {formatNumber(availableStock)} | pending:{' '}
<span className='text-error'>{formatNumber(pendingQty)}</span> |
pakai: {formatNumber(requestedUsage + pendingQty)})
pakai: {formatNumber(requestedUsage)})
</span>
);
}
@@ -1494,10 +1609,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return (
idx !== currentIdx &&
s.product_warehouse_id &&
s.product_warehouse_id !== 0
s.product_warehouse_id.value !== 0
);
})
.map((s) => s.product_warehouse_id) || [];
.map((s) => s.product_warehouse_id?.value) || [];
return unifiedStockProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1514,10 +1629,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return (
idx !== currentIdx &&
d.product_warehouse_id &&
d.product_warehouse_id !== 0
d.product_warehouse_id.value !== 0
);
})
.map((d) => d.product_warehouse_id) || [];
.map((d) => d.product_warehouse_id?.value) || [];
return depletionProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1534,10 +1649,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return (
idx !== currentIdx &&
e.product_warehouse_id &&
e.product_warehouse_id !== 0
e.product_warehouse_id.value !== 0
);
})
.map((e) => e.product_warehouse_id) || [];
.map((e) => e.product_warehouse_id?.value) || [];
return eggProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1583,7 +1698,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isError: touchedField && Boolean(errorField?.[column]),
errorMessage:
touchedField && errorField?.[column]
? (errorField[column] as string)
? errorField[column] instanceof Object
? (errorField[column] as OptionType)?.label
: (errorField[column] as string)
: '',
};
};
@@ -1614,14 +1731,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
@@ -1629,7 +1746,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1678,14 +1795,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
@@ -1693,7 +1810,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1731,14 +1848,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
]);
@@ -1746,7 +1863,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
weight: '',
},
@@ -1959,7 +2076,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newStocks = [
...(formik.values.stocks || []),
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
];
@@ -1991,7 +2108,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newDepletions = [
...(formik.values.depletions || []),
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
];
@@ -2025,7 +2142,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newEggs = [
...((formik.values as RecordingLayingFormValues).eggs || []),
{
product_warehouse_id: 0,
product_warehouse_id: null,
qty: '',
},
];
@@ -2068,7 +2185,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') {
const layingValues = formik.values as RecordingLayingFormValues;
if (!layingValues.eggs || layingValues.eggs.length === 0) {
setFieldValue('eggs', [{ product_warehouse_id: 0, qty: '' }]);
setFieldValue('eggs', [{ product_warehouse_id: null, qty: '' }]);
}
}
}, [isLayingCategory, type, formik.values, setFieldValue]);
@@ -2790,20 +2907,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<td>
<SelectInput
required
key={`stock-product-${idx}-${stock.product_warehouse_id}`}
value={
unifiedStockProducts.find(
(product) =>
product.value === stock.product_warehouse_id
) || null
}
onInputChange={setStockProductInputValue}
key={`stock-product-${idx}-${stock.product_warehouse_id?.value}`}
value={stock.product_warehouse_id}
onInputChange={setStockInputValue}
onChange={(selectedOption) => {
const option =
selectedOption as OptionType | null;
formik.setFieldValue(
`stocks.${idx}.product_warehouse_id`,
option?.value || 0
option
);
}}
options={getAvailableStockProductOptions(idx)}
@@ -2839,9 +2951,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}
isClearable={type !== 'detail'}
inputPrefix={
stock.product_warehouse_id
stock.product_warehouse_id?.value
? getProductFlagBadgeAdornment(
stock.product_warehouse_id
stock.product_warehouse_id.value
)
: undefined
}
@@ -2877,7 +2989,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
inputSuffix={
stock.product_warehouse_id
? getProductUomSuffix(
stock.product_warehouse_id,
stock.product_warehouse_id.value,
'stock'
)
: null
@@ -3070,19 +3182,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)}
<td>
<SelectInput
value={
depletionProducts.find(
(product) =>
product.value ===
depletion.product_warehouse_id
) || null
}
value={depletion.product_warehouse_id}
onChange={(selectedOption) => {
const option =
selectedOption as OptionType | null;
formik.setFieldValue(
`depletions.${idx}.product_warehouse_id`,
option?.value || 0
option
);
}}
options={getAvailableDepletionProductOptions(idx)}
@@ -3145,7 +3251,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
inputSuffix={
depletion.product_warehouse_id
? getProductUomSuffix(
depletion.product_warehouse_id,
depletion.product_warehouse_id.value,
'depletion'
)
: null
@@ -3323,18 +3429,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)}
<td>
<SelectInput
value={
eggProducts.find(
(product) =>
product.value === egg.product_warehouse_id
) || null
}
value={egg.product_warehouse_id}
onChange={(selectedOption) => {
const option =
selectedOption as OptionType | null;
formik.setFieldValue(
`eggs.${idx}.product_warehouse_id`,
option?.value || 0
option
);
}}
options={getAvailableEggProductOptions(idx)}
@@ -40,6 +40,9 @@ const TransferToLayingDetailModal = () => {
? transferToLayingResponse.data
: undefined;
const isTransferToLayingApproved =
transferToLaying?.approval.step_number === 2;
const { data: transferToLayingApprovalResponse } = useSWR(
transferToLayingId
? ['approval-transfer-to-laying', transferToLayingId]
@@ -55,9 +58,9 @@ const TransferToLayingDetailModal = () => {
const detailModal = useModal();
const totalEnteredChickenForTransfer =
const maxSourceQuantity =
transferToLaying?.sources.reduce(
(acc, item) => acc + Number(item.qty),
(acc, item) => acc + Number(item.product_warehouse.quantity),
0
) ?? 0;
@@ -67,8 +70,9 @@ const TransferToLayingDetailModal = () => {
0
) ?? 0;
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
const totalAvailableChickenForTransfer =
totalEnteredChickenForTransfer - totalTransferedChicken;
maxSourceQuantity - totalTransferedChicken;
const closeModalHandler = (shouldPushToRoute: boolean = true) => {
if (shouldPushToRoute) {
@@ -161,11 +165,34 @@ const TransferToLayingDetailModal = () => {
{/* Source Kandang */}
<div className='flex flex-col'>
<span className='w-full py-2 text-xs font-semibold'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
<span className='text-nowrap'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</span>
{!isTransferToLayingApproved && (
<>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa ayam: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</>
)}
</span>
{transferToLaying?.sources.length === 0 && (
@@ -225,21 +252,6 @@ const TransferToLayingDetailModal = () => {
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0 ? 'error' : 'neutral'
}
text={`Sisa transfer: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{transferToLaying?.targets.length === 0 && (
@@ -304,7 +316,7 @@ const TransferToLayingDetailModal = () => {
readOnly
errorMessage={
totalAvailableChickenForTransfer < 0
? `Jumlah transfer melebihi ketersediaan (${formatNumber(totalEnteredChickenForTransfer, 'en-US')} ayam)`
? `Jumlah transfer melebihi ketersediaan (${formatNumber(maxSourceQuantity, 'en-US')} ayam)`
: ''
}
/>
@@ -13,7 +13,6 @@ import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { ProjectFlockApi } from '@/services/api/production';
import { Flock } from '@/types/api/master-data/flock';
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
import {
TransferToLayingFilterSchema,
TransferToLayingFilterValues,
@@ -21,12 +20,14 @@ import {
interface TransferToLayingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: TransferToLayingFilter) => void;
initialValues?: Partial<TransferToLayingFilterValues>;
onSubmit?: (values: TransferToLayingFilterValues) => void;
onReset?: () => void;
}
const TransferToLayingFilterModal = ({
ref,
initialValues: initialValuesProp,
onSubmit,
onReset,
}: TransferToLayingFilterModal) => {
@@ -86,28 +87,16 @@ const TransferToLayingFilterModal = ({
const formik = useFormik<TransferToLayingFilterValues>({
initialValues: {
startDate: '',
endDate: '',
flockSource: [],
flockDestination: [],
status: [],
startDate: initialValuesProp?.startDate ?? '',
endDate: initialValuesProp?.endDate ?? '',
flockSource: initialValuesProp?.flockSource ?? [],
flockDestination: initialValuesProp?.flockDestination ?? [],
status: initialValuesProp?.status ?? [],
},
enableReinitialize: true,
validationSchema: TransferToLayingFilterSchema,
onSubmit: async (values) => {
const formattedValues = {
...values,
flockSource: values.flockSource
? (values.flockSource as OptionType[]).map((item) => item.value)
: [],
flockDestination: values.flockDestination
? (values.flockDestination as OptionType[]).map((item) => item.value)
: [],
status: values.status
? (values.status as OptionType[]).map((item) => item.value)
: [],
};
onSubmit?.(formattedValues as TransferToLayingFilter);
onSubmit?.(values);
closeModalHandler();
},
onReset: () => {
@@ -223,6 +223,8 @@ const TransferToLayingFormModal = () => {
},
});
const { flockSource: formikFlockSource } = formik.values;
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState<
@@ -231,13 +233,8 @@ const TransferToLayingFormModal = () => {
const [maxSourceQuantity, setMaxSourceQuantity] = useState<number>(0);
const selectedFlockDestinationRawData = isResponseSuccess(
flockDestinationRawData
)
? flockDestinationRawData.data.find(
(item) => item.id === formik.values.flockDestination?.value
)
: undefined;
const [selectedFlockDestinationRawData, setSelectedFlockDestinationRawData] =
useState<ProjectFlock | undefined>(undefined);
const { data: flockSourceKandangsAvailability } = useSWR(
formik.values.flockSource
@@ -454,14 +451,36 @@ const TransferToLayingFormModal = () => {
}, [transferToLayingId, transferToLaying]);
useEffect(() => {
if (!formik.values.flockSource) {
setSelectedFlockSourceRawData(undefined);
return;
}
if (isResponseSuccess(flockSourceRawData)) {
const selectedFlockSourceRawData = flockSourceRawData.data.find(
const found = flockSourceRawData.data.find(
(item) => item.id === formik.values.flockSource?.value
);
setSelectedFlockSourceRawData(selectedFlockSourceRawData);
if (found) {
setSelectedFlockSourceRawData(found);
}
}
}, [flockSourceRawData]);
}, [flockSourceRawData, formikFlockSource]);
useEffect(() => {
if (!formik.values.flockDestination) {
setSelectedFlockDestinationRawData(undefined);
return;
}
if (isResponseSuccess(flockDestinationRawData)) {
const found = flockDestinationRawData.data.find(
(item) => item.id === formik.values.flockDestination?.value
);
if (found) {
setSelectedFlockDestinationRawData(found);
}
}
}, [flockDestinationRawData, formik.values.flockDestination]);
useEffect(() => {
formik.setFieldValue('totalQuantity', totalTransferedChicken);
@@ -625,6 +644,7 @@ const TransferToLayingFormModal = () => {
>
<div className='flex flex-row items-center gap-3'>
<input
id={`flock-source-kandang-${item.project_flock_kandang_id}`}
type='radio'
name='flockSourceKandang'
value={item.project_flock_kandang_id}
@@ -637,13 +657,14 @@ const TransferToLayingFormModal = () => {
/>
<label
htmlFor={`flock-source-kandang-${item.project_flock_kandang_id}`}
className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable,
'cursor-not-allowed opacity-50': !isAvailable,
})}
>
{item.kandang_name}{' '}
<span className='text-base-content/20'>{`(Max: ${item.available_qty})`}</span>
<span className='text-base-content/20'>{`(Max: ${item.available_qty ?? '-'})`}</span>
</label>
</div>
@@ -818,11 +839,33 @@ const TransferToLayingFormModal = () => {
{/* Source Kandang */}
<div className='flex flex-col'>
<span className='w-full py-2 text-xs font-semibold'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
<span className='text-nowrap'>
Kandang Asal{' '}
<span
className='tooltip tooltip-error'
data-tip='required'
>
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa ayam: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{formik.values.flockSourceKandangs.length === 0 && (
@@ -902,23 +945,6 @@ const TransferToLayingFormModal = () => {
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa transfer: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{formik.values.flockDestinationKandangs.length === 0 && (
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr';
@@ -26,10 +26,9 @@ import TransferToLayingFilterModal from '@/components/pages/production/transfer-
import TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal';
import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton';
import {
TransferToLaying,
TransferToLayingFilter,
} from '@/types/api/production/transfer-to-laying';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
import { TransferToLayingFilterValues } from '@/components/pages/production/transfer-to-laying/filter/TransferToLayingFilter';
import { OptionType } from '@/components/input/SelectInput';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -142,6 +141,8 @@ const TransferToLayingsTable = () => {
status: '',
filter_by: '',
sort_by: '',
flockSourceNames: '',
flockDestinationNames: '',
},
paramMap: {
page: 'page',
@@ -154,6 +155,9 @@ const TransferToLayingsTable = () => {
filter_by: 'filter_by',
sort_by: 'sort_by',
},
excludeKeysFromUrl: ['flockSourceNames', 'flockDestinationNames'],
persist: true,
storeName: 'transfer-to-laying-table',
});
const {
@@ -431,12 +435,84 @@ const TransferToLayingsTable = () => {
updateFilter('search', e.target.value);
};
const filterSubmitHandler = (values: TransferToLayingFilter) => {
updateFilter('startDate', values.startDate);
updateFilter('endDate', values.endDate);
updateFilter('flockSource', values.flockSource.join(','));
updateFilter('flockDestination', values.flockDestination.join(','));
updateFilter('status', values.status.join(','));
const STATUS_FILTER_OPTIONS = [
{ value: 'PENDING', label: 'Pengajuan' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const filterModalInitialValues = useMemo(() => {
const flockSourceIds = tableFilterState.flockSource
? tableFilterState.flockSource.split(',')
: [];
const flockSourceNameList = tableFilterState.flockSourceNames
? tableFilterState.flockSourceNames.split(',')
: [];
const flockSourceOptions = flockSourceIds.filter(Boolean).map((id, i) => ({
value: parseInt(id),
label: flockSourceNameList[i] || id,
}));
const flockDestIds = tableFilterState.flockDestination
? tableFilterState.flockDestination.split(',')
: [];
const flockDestNameList = tableFilterState.flockDestinationNames
? tableFilterState.flockDestinationNames.split(',')
: [];
const flockDestOptions = flockDestIds.filter(Boolean).map((id, i) => ({
value: parseInt(id),
label: flockDestNameList[i] || id,
}));
const statusIds = tableFilterState.status
? tableFilterState.status.split(',')
: [];
const statusOptions = statusIds.filter(Boolean).map((id) => {
const found = STATUS_FILTER_OPTIONS.find((opt) => opt.value === id);
return found || { value: id, label: id };
});
return {
startDate: tableFilterState.startDate || '',
endDate: tableFilterState.endDate || '',
flockSource: flockSourceOptions,
flockDestination: flockDestOptions,
status: statusOptions,
};
}, [
tableFilterState.startDate,
tableFilterState.endDate,
tableFilterState.flockSource,
tableFilterState.flockDestination,
tableFilterState.status,
tableFilterState.flockSourceNames,
tableFilterState.flockDestinationNames,
]);
const filterSubmitHandler = (values: TransferToLayingFilterValues) => {
const flockSourceOpts = (values.flockSource as OptionType[]) || [];
const flockDestOpts = (values.flockDestination as OptionType[]) || [];
const statusOpts = (values.status as OptionType[]) || [];
updateFilter('startDate', values.startDate || '');
updateFilter('endDate', values.endDate || '');
updateFilter(
'flockSource',
flockSourceOpts.map((o) => String(o.value)).join(',')
);
updateFilter(
'flockDestination',
flockDestOpts.map((o) => String(o.value)).join(',')
);
updateFilter('status', statusOpts.map((o) => String(o.value)).join(','));
updateFilter(
'flockSourceNames',
flockSourceOpts.map((o) => String(o.label)).join(',')
);
updateFilter(
'flockDestinationNames',
flockDestOpts.map((o) => String(o.label)).join(',')
);
};
const filterResetHandler = () => {
@@ -445,6 +521,8 @@ const TransferToLayingsTable = () => {
updateFilter('flockSource', '');
updateFilter('flockDestination', '');
updateFilter('status', '');
updateFilter('flockSourceNames', '');
updateFilter('flockDestinationNames', '');
};
const exportToExcelHandler = async () => {
@@ -558,6 +636,8 @@ const TransferToLayingsTable = () => {
'search',
'filter_by',
'sort_by',
'flockSourceNames',
'flockDestinationNames',
]}
fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal}
@@ -670,6 +750,7 @@ const TransferToLayingsTable = () => {
<TransferToLayingFilterModal
ref={filterModal.ref}
initialValues={filterModalInitialValues}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
@@ -3,7 +3,6 @@ import { Uniformity } from '@/types/api/production/uniformity';
type UniformityFormSchemaType = {
date: string;
week: number;
location?: {
value: number;
label: string;
@@ -45,10 +44,6 @@ const FileSchema = Yup.mixed<File>()
export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> =
Yup.object({
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({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
@@ -81,7 +76,6 @@ export type UniformityFormValues = Yup.InferType<typeof UniformityFormSchema>;
export type UniformityFormData = {
date: string;
week: number;
project_flock_kandang_id: number;
document: File | null;
document_name: string;
@@ -91,8 +85,7 @@ export const getUniformityFormInitialValues = (
initialValues?: Partial<Uniformity>
): UniformityFormValues => {
return {
date: initialValues?.week ? '' : '',
week: initialValues?.week ?? 0,
date: '',
location: null,
location_id: 0,
project_flock: null,
@@ -27,7 +27,6 @@ import { LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
RecordingApi,
} from '@/services/api/production';
import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -40,7 +39,6 @@ import {
ProjectFlockKandangLookup,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { Recording } from '@/types/api/production/recording';
import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -204,23 +202,6 @@ const UniformityForm = ({
? projectFlockKandangLookupData.data
: 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 =====
const formikInitialValues = useMemo<UniformityFormValues>(
() => getUniformityFormInitialValues(initialValues),
@@ -246,7 +227,6 @@ const UniformityForm = ({
setUniformityFormData({
date: values.date,
week: values.week,
project_flock_kandang_id: projectFlockKandangId,
document: values.document as File,
document_name: (values.document as File).name,
@@ -475,59 +455,6 @@ const UniformityForm = ({
generateUniformityTemplate(population, 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(() => {
const unsub = subscribeValidate(() => {
setIsValid(true);
@@ -63,7 +63,6 @@ const UniformityResultForm = () => {
try {
const payload = {
date: uniformityFormData.date,
week: uniformityFormData.week,
project_flock_kandang_id: uniformityFormData.project_flock_kandang_id,
document: uniformityFormData.document,
};
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useState, useEffect } from 'react';
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -9,31 +9,61 @@ 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 SelectInputRadio from '@/components/input/SelectInputRadio';
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';
const filterByOptions: OptionType<string>[] = [
{ value: 'po_date', label: 'Tanggal PO' },
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'due_date', label: 'Tanggal Jatuh Tempo' },
{ value: 'created_at', label: 'Tanggal Dibuat' },
];
interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: {
poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: OptionType<number>[];
status: OptionType<string>[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
onSubmit?: (values: PurchaseFilter) => void;
onReset?: () => void;
}
const PurchaseFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: PurchaseFilterModalProps) => {
const closeModalHandler = () => {
const closeModalHandler = useCallback(() => {
ref.current?.close();
};
}, [ref]);
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => {
@@ -73,32 +103,140 @@ const PurchaseFilterModal = ({
'search'
);
const [selectedAreaId, setSelectedAreaId] = useState(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
const [selectedLocationId, setSelectedLocationId] = useState(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedAreaId || '',
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<{
poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: { label: string; value: number }[];
status: { label: string; value: string }[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
}>({
initialValues: {
// enableReinitialize: true,
initialValues: initialValues || {
poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
onSubmit: async (values) => {
const formattedValues = {
...values,
category: values.category.map((item) => String(item.value)),
category_labels: values.category,
status: values.status.map((item) => String(item.value)),
supplier_id: values.supplier?.value,
supplier_label: values.supplier?.label,
area_id: values.area?.value,
area_label: values.area?.label,
location_id: values.location?.value,
location_label: values.location?.label,
project_flock_id: values.project_flock?.value,
project_flock_label: values.project_flock?.label,
project_flock_kandang_id: values.project_flock_kandang?.value,
project_flock_kandang_label: values.project_flock_kandang?.label,
};
onSubmit?.(formattedValues);
closeModalHandler();
},
onReset: () => {
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
},
});
const { resetForm, submitForm } = formik;
useEffect(() => {
setSelectedAreaId(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.area, initialValues?.location]);
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
const productCategoryChangeHandler = (
val: OptionType | OptionType[] | null
) => {
@@ -109,6 +247,86 @@ const PurchaseFilterModal = ({
formik.setFieldValue('status', val);
};
const formikResetHandler = useCallback(() => {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
resetForm({
values: {
poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler, dateErrorShown]);
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const formikSubmitHandler = useCallback(async () => {
await submitForm();
}, [submitForm]);
return (
<Modal
ref={ref}
@@ -118,7 +336,7 @@ const PurchaseFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -132,7 +350,9 @@ const PurchaseFilterModal = ({
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
onClick={() => {
closeModalHandler();
}}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
@@ -142,6 +362,44 @@ const PurchaseFilterModal = ({
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
/>
<DateInput
label='PO Date'
name='poDate'
@@ -172,6 +430,108 @@ const PurchaseFilterModal = ({
value: item.step_name,
}))}
/>
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
value={formik.values.supplier}
onChange={(val) =>
formik.setFieldValue(
'supplier',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isClearable
/>
<SelectInput
label='Area'
placeholder='Pilih Area'
value={formik.values.area}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('area', nextValue);
formik.setFieldValue('location', null);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedAreaId(
nextValue?.value ? String(nextValue.value) : ''
);
setSelectedLocationId('');
}}
options={areaOptions}
isLoading={isLoadingAreaOptions}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
value={formik.values.location}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('location', nextValue);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(
nextValue?.value ? String(nextValue.value) : ''
);
}}
options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!formik.values.area}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
value={formik.values.project_flock}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('project_flock', nextValue);
formik.setFieldValue('project_flock_kandang', null);
}}
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!formik.values.location}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={projectFlockKandangOptions}
isClearable
isDisabled={!formik.values.project_flock}
/>
</div>
</div>
@@ -187,7 +547,9 @@ const PurchaseFilterModal = ({
</Button>
<Button
type='submit'
type='button'
onClick={formikSubmitHandler}
disabled={hasDateError}
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
+687 -124
View File
@@ -1,25 +1,22 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import {
CellContext,
ColumnDef,
SortingState,
Updater,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import DateInput from '@/components/input/DateInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -28,17 +25,40 @@ import StatusBadge from '@/components/helper/StatusBadge';
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter';
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
import Dropdown from '@/components/dropdown/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import { cn, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
type PurchaseTableFilters = {
search: string;
sort_by: string;
order_by: string;
start_date: string;
end_date: string;
filter_by: string;
po_date: string;
approval_status: string;
product_category_id: string;
product_category_name: string;
supplier_id: string;
supplier_name: string;
area_id: string;
area_name: string;
location_id: string;
location_name: string;
project_flock_id: string;
project_flock_name: string;
project_flock_kandang_id: string;
project_flock_kandang_name: string;
};
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -147,42 +167,100 @@ const RowOptionsMenu = ({
};
const PurchaseTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [sorting, setSorting] = useState<SortingState>([]);
// ===== TABLE FILTER STATE =====
const {
state: tableFilterState,
setFilters,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<PurchaseTableFilters>({
initial: {
search: '',
sort_by: '',
order_by: '',
start_date: '',
end_date: '',
filter_by: '',
po_date: '',
approval_status: '',
product_category_id: '',
product_category_name: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
sort_by: 'sort_by',
order_by: 'sort_order',
start_date: 'start_date',
end_date: 'end_date',
filter_by: 'filter_by',
po_date: 'po_date',
approval_status: 'approval_status',
product_category_id: 'product_category_id',
supplier_id: 'supplier_id',
area_id: 'area_id',
location_id: 'location_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
},
excludeKeysFromUrl: [
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'purchase-table',
});
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// ===== MODAL HOOKS =====
const filterModal = useModal();
const deleteModal = useModal();
const exportProgressInputModal = useModal();
// ===== API DATA FETCHING =====
const {
@@ -194,36 +272,10 @@ const PurchaseTable = () => {
PurchaseApi.getAllFetcher
);
const getKey = (
pageIndex: number,
previousPageData: BaseApiResponse<Expense>[] | null
) => {
if (pageIndex > 0 && !previousPageData) return null;
return `${ExpenseApi.basePath}?page=${pageIndex + 1}&limit=100`;
};
const { data: expensesPages } = useSWRInfinite(
getKey,
ExpenseApi.getAllFetcher
);
const expenseMap = useMemo(() => {
const map = new Map<string, number>();
if (!expensesPages) return map;
expensesPages.forEach((page) => {
if (isResponseSuccess(page)) {
page.data.forEach((expense: Expense) => {
map.set(expense.reference_number, expense.id);
});
}
});
return map;
}, [expensesPages]);
// ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [
{
accessorKey: 'po_number',
header: 'No. PR/PO',
cell: (props) => {
const { pr_number, po_number } = props.row.original;
@@ -239,37 +291,91 @@ const PurchaseTable = () => {
return (
<ul className='list-disc pl-4'>
{poExpedition.map((exp, index) => {
const expenseId = expenseMap.get(exp.refrence);
if (expenseId) {
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${expenseId}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
}
return <li key={index}>{exp.refrence}</li>;
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${exp.id}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
})}
</ul>
);
},
},
{
accessorKey: 'requester_name',
header: 'Nama Pengaju',
cell: (props) => props.row.original.requester_name || '-',
},
{
accessorKey: 'po_date',
header: 'Tgl. PO',
cell: (props) =>
props.row.original.po_date
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'received_date',
header: 'Tgl. Terima',
cell: (props) =>
props.row.original.received_date
? formatDate(props.row.original.received_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
cell: (props) =>
props.row.original.due_date
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
: '-',
},
{
header: 'Aging',
enableSorting: false,
cell: (props) => {
const purchase = props.row.original;
if (!purchase.po_date) return '-';
const poDate = new Date(purchase.po_date);
const today = new Date();
const diffTime = Math.abs(today.getTime() - poDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return `${diffDays} hari`;
},
},
{
accessorKey: 'supplier',
header: 'Vendor',
cell: (props) => props.row.original.supplier.name,
},
{
accessorKey: 'requester_name',
header: 'Nama Pengaju',
cell: (props) => props.row.original.requester_name || '-',
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{
accessorKey: 'products.name',
accessorKey: 'warehouse',
header: 'Gudang',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.warehouse?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'products',
header: 'Produk',
cell: (props) => {
const products = props.row.original.products;
@@ -284,39 +390,162 @@ const PurchaseTable = () => {
},
},
{
accessorKey: 'location.name',
header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{
accessorKey: 'po_date',
header: 'Tgl. PO',
cell: (props) =>
props.row.original.po_date
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
cell: (props) =>
props.row.original.due_date
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
: '-',
},
{
header: 'Aging',
accessorKey: 'total_qty',
header: 'Kuantitas',
enableSorting: false,
cell: (props) => {
const purchase = props.row.original;
if (!purchase.po_date) return '-';
const poDate = new Date(purchase.po_date);
const today = new Date();
const diffTime = Math.abs(today.getTime() - poDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return `${diffDays} hari`;
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatNumber(item.total_qty ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'uom',
header: 'Satuan',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.product?.uom?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'price',
header: 'Harga',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatCurrency(item.price ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'total_price',
header: 'Total Harga',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatCurrency(item.total_price ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'products_total',
header: 'Total Harga Produk',
cell: (props) => formatCurrency(props.row.original.products_total ?? 0),
},
{
accessorKey: 'expedition_vendor',
header: 'Vendor Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.expedition_vendor?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'expedition_qty',
header: 'Qty Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.expedition_qty != null
? formatNumber(item.expedition_qty)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'transport_per_item',
header: 'Harga Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.transport_per_item != null
? formatCurrency(item.transport_per_item)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'item_expedition_total',
header: 'Total Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.expedition_total != null
? formatCurrency(item.expedition_total)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'expedition_total',
header: 'Total Ekspedisi Semua Produk',
cell: (props) => formatCurrency(props.row.original.expedition_total ?? 0),
},
{
accessorKey: 'grand_total_all',
header: 'Grand Total All',
cell: (props) => formatCurrency(props.row.original.grand_total_all ?? 0),
},
{
accessorKey: 'status',
header: 'Status Approval',
cell: (props) => {
const approval = props.row.original.latest_approval;
@@ -361,6 +590,19 @@ const PurchaseTable = () => {
);
},
},
{
accessorKey: 'notes',
header: 'Notes',
cell: (props) => props.row.original.notes || '-',
},
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM YYYY')
: '-',
},
{
header: 'Aksi',
cell: (props) => {
@@ -392,10 +634,17 @@ const PurchaseTable = () => {
setIsDeleteLoading(true);
try {
await PurchaseApi.delete(selectedPurchase?.id as number);
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
const deleteResponse = await PurchaseApi.delete(
selectedPurchase?.id as number
);
if (isResponseSuccess(deleteResponse)) {
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
} else {
toast.error(deleteResponse?.message ?? 'Gagal menghapus data!');
}
} catch {
toast.error('Gagal menghapus data permintaan pembelian!');
}
@@ -403,34 +652,214 @@ const PurchaseTable = () => {
setIsDeleteLoading(false);
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('purchase-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
},
[updateFilter, setSearchValue]
[updateFilter]
);
const filterSubmitHandler = (values: PurchaseFilter) => {
updateFilter('po_date', values.poDate);
updateFilter('product_category_id', values.category.join(','));
updateFilter('approval_status', values.status.join(','));
setFilters({
start_date: values.start_date || '',
end_date: values.end_date || '',
filter_by: values.filterBy?.value || '',
po_date: values.poDate,
product_category_id: values.category.join(','),
product_category_name:
values.category_labels?.map((item) => item.label).join(',') || '',
approval_status: values.status.join(','),
supplier_id: values.supplier_id ? String(values.supplier_id) : '',
supplier_name: values.supplier_label || '',
area_id: values.area_id ? String(values.area_id) : '',
area_name: values.area_label || '',
location_id: values.location_id ? String(values.location_id) : '',
location_name: values.location_label || '',
project_flock_id: values.project_flock_id
? String(values.project_flock_id)
: '',
project_flock_name: values.project_flock_label || '',
project_flock_kandang_id: values.project_flock_kandang_id
? String(values.project_flock_kandang_id)
: '',
project_flock_kandang_name: values.project_flock_kandang_label || '',
});
};
const filterResetHandler = () => {
updateFilter('po_date', '');
updateFilter('product_category_id', '');
updateFilter('approval_status', '');
setFilters({
start_date: '',
end_date: '',
filter_by: '',
po_date: '',
product_category_id: '',
product_category_name: '',
approval_status: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
});
};
const purchaseFilterInitialValues = useMemo(() => {
const filterByLabelMap: Record<string, string> = {
po_date: 'Tanggal PO',
received_date: 'Tanggal Terima',
due_date: 'Tanggal Jatuh Tempo',
created_at: 'Tanggal Dibuat',
};
const categoryIds = tableFilterState.product_category_id
? tableFilterState.product_category_id
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const categoryLabels = tableFilterState.product_category_name
? tableFilterState.product_category_name
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const approvalStatuses = tableFilterState.approval_status
? tableFilterState.approval_status
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return {
poDate: tableFilterState.po_date,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
filterBy: tableFilterState.filter_by
? {
value: tableFilterState.filter_by,
label:
filterByLabelMap[tableFilterState.filter_by] ||
tableFilterState.filter_by,
}
: undefined,
category: categoryIds.map((value, index) => ({
value: Number(value),
label: categoryLabels[index] || value,
})),
status: approvalStatuses.map((value) => ({
value,
label:
PURCHASE_ORDER_APPROVAL_LINE.find((item) => item.step_name === value)
?.step_name || value,
})),
supplier: tableFilterState.supplier_id
? ({
value: Number(tableFilterState.supplier_id),
label:
tableFilterState.supplier_name || tableFilterState.supplier_id,
} as OptionType<number>)
: null,
area: tableFilterState.area_id
? ({
value: Number(tableFilterState.area_id),
label: tableFilterState.area_name || tableFilterState.area_id,
} as OptionType<number>)
: null,
location: tableFilterState.location_id
? ({
value: Number(tableFilterState.location_id),
label:
tableFilterState.location_name || tableFilterState.location_id,
} as OptionType<number>)
: null,
project_flock: tableFilterState.project_flock_id
? ({
value: Number(tableFilterState.project_flock_id),
label:
tableFilterState.project_flock_name ||
tableFilterState.project_flock_id,
} as OptionType<number>)
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? ({
value: Number(tableFilterState.project_flock_kandang_id),
label:
tableFilterState.project_flock_kandang_name ||
tableFilterState.project_flock_kandang_id,
} as OptionType<number>)
: null,
};
}, [tableFilterState]);
const exportToExcel = useCallback(async () => {
setIsLoadingExportingToExcel(true);
try {
await PurchaseApi.exportToExcel(getTableFilterQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pembelian')
);
} finally {
setIsLoadingExportingToExcel(false);
}
}, [getTableFilterQueryString]);
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressStartDate(e.target.value);
}, []);
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressEndDate(e.target.value);
}, []);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await PurchaseApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
return (
<>
<div className='w-full'>
@@ -477,11 +906,73 @@ const PurchaseTable = () => {
'search',
'filter_by',
'sort_by',
'order_by',
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
]}
fieldGroups={[['startDate', 'endDate']]}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcel}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
</div>
@@ -529,7 +1020,8 @@ const PurchaseTable = () => {
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
@@ -543,6 +1035,7 @@ const PurchaseTable = () => {
<PurchaseFilterModal
ref={filterModal.ref}
initialValues={purchaseFilterInitialValues}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
@@ -562,6 +1055,76 @@ const PurchaseTable = () => {
onClick: confirmationModalDeleteClickHandler,
}}
/>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
</>
);
};
@@ -294,7 +294,6 @@ const PurchaseOrderAcceptApprovalForm = ({
item.expedition_vendor_id || item.expedition_vendor?.id || null;
return {
purchase_item: null,
purchase_item_id: item.id,
received_date: item.received_date
? new Date(item.received_date).toISOString().split('T')[0]
@@ -573,7 +572,7 @@ const PurchaseOrderAcceptApprovalForm = ({
<td>
<SelectInput
isClearable={true}
value={formItem?.expedition_vendor}
value={formItem?.expedition_vendor ?? null}
key={`expedition-vendor-${idx}`}
onChange={(val) =>
expeditionVendorChangeHandler(idx, val)
@@ -31,10 +31,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null;
items: {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
@@ -68,10 +64,6 @@ export type PurchaseStaffApprovalItemSchema = {
};
export type PurchaseAcceptApprovalItemSchema = {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
@@ -160,12 +152,6 @@ const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManag
const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApprovalItemSchema> =
Yup.object({
purchase_item: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
purchase_item_id: Yup.number()
.min(1, 'Purchase item is required!')
.required('Purchase item is required!')
@@ -185,9 +171,8 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.typeError('No. Surat jalan wajib diisi!'),
vehicle_number: Yup.string()
.nullable()
.when('expedition_vendor', {
is: (expeditionVendor?: { value?: number; label?: string } | null) =>
Boolean(expeditionVendor?.value),
.when('expedition_vendor_id', {
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
then: (schema) => schema.required('Nomor kendaraan wajib diisi!'),
otherwise: (schema) => schema.optional(),
})
@@ -196,6 +181,7 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.default(undefined)
.nullable()
.optional(),
expedition_vendor_id: Yup.number()
@@ -218,9 +204,8 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.typeError('Jumlah diterima harus berupa angka!'),
transport_per_item: Yup.mixed<string | number>()
.nullable()
.when('expedition_vendor', {
is: (expeditionVendor?: { value?: number; label?: string } | null) =>
Boolean(expeditionVendor?.value),
.when('expedition_vendor_id', {
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
then: (schema) =>
schema.required('Biaya transport per item wajib diisi!'),
otherwise: (schema) => schema.optional(),
@@ -55,7 +55,6 @@ const PurchaseRequestForm = ({
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [, setLocationSelectInputValue] = useState('');
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
[]
);
@@ -163,6 +162,7 @@ const PurchaseRequestForm = ({
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
setInputValue: setLocationSelectInputValue,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
@@ -26,6 +26,8 @@ import PurchaseOrderAcceptApprovalForm from '@/components/pages/purchase/form/or
import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import {
CreateAcceptApprovalRequestPayload,
CreateManagerApprovalRequestPayload,
@@ -96,6 +98,7 @@ const PurchaseOrderDetail = ({
const acceptRejectionModal = useModal();
const managerRejectionModal = useModal();
const editModal = useModal();
const editPoDateModal = useModal();
const penerimaanBarangModal = useModal();
const deleteModal = useModal();
@@ -105,6 +108,9 @@ const PurchaseOrderDetail = ({
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<PurchaseItem | null>(null);
const [, setApprovalNotes] = useState('');
const [managerApprovalNotes, setManagerApprovalNotes] = useState('');
const [managerApprovalPoDate, setManagerApprovalPoDate] = useState('');
const [editPoDate, setEditPoDate] = useState('');
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
@@ -212,6 +218,8 @@ const PurchaseOrderDetail = ({
break;
case 2:
setApprovalNotes('');
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.openModal();
break;
case 3:
@@ -414,17 +422,50 @@ const PurchaseOrderDetail = ({
deleteModal,
]);
const updatePoDateHandler = useCallback(async () => {
const purchaseRequestId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: initialValues?.id || 1;
if (!purchaseRequestId) {
toast.error('Purchase Request ID is required');
return;
}
const res = await PurchaseApi.updatePoDate(purchaseRequestId, {
po_date: editPoDate,
});
if (isResponseError(res)) {
toast.error(res.message || 'Gagal mengubah tanggal PO');
return;
}
toast.success('Tanggal PO berhasil diubah');
setEditPoDate('');
editPoDateModal.closeModal();
refetchData?.();
}, [
initialValues?.id,
searchParams,
editPoDate,
editPoDateModal,
refetchData,
]);
// ===== APPROVAL/REJECTION HANDLERS =====
const managerApprovalHandler = async (notes: string) => {
const managerApprovalHandler = async () => {
const payload: CreateManagerApprovalRequestPayload = {
action: 'APPROVED',
notes: notes || null,
notes: managerApprovalNotes || null,
po_date: managerApprovalPoDate || null,
};
await createManagerApprovalHandler(payload);
await refreshApprovals();
await refetchData?.();
setApprovalNotes('');
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
};
@@ -806,9 +847,25 @@ const PurchaseOrderDetail = ({
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Nomor
</span>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.pr_number}
</span>
<div className='flex items-center gap-1'>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.pr_number}
</span>
<Button
type='button'
variant='ghost'
color='none'
className='p-1 min-h-0 h-auto'
onClick={() => {
navigator.clipboard.writeText(
purchaseData.pr_number || ''
);
toast.success('Nomor berhasil disalin');
}}
>
<Icon icon='mdi:content-copy' width={14} height={14} />
</Button>
</div>
</div>
</div>
<div className='group'>
@@ -816,11 +873,31 @@ const PurchaseOrderDetail = ({
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Nomor PO
</span>
<div className='ml-3'>
<div className='ml-3 flex items-center gap-1'>
{canShowPurchaseOrderInvoice &&
purchaseData.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<PurchaseOrderInvoice data={purchaseData} />
<>
<PurchaseOrderInvoice data={purchaseData} />
<Button
type='button'
variant='ghost'
color='none'
className='p-1 min-h-0 h-auto'
onClick={() => {
navigator.clipboard.writeText(
purchaseData.po_number || ''
);
toast.success('Nomor PO berhasil disalin');
}}
>
<Icon
icon='mdi:content-copy'
width={14}
height={14}
/>
</Button>
</>
) : (
<>
: <i className='text-gray-400'>Belum dibuat</i>
@@ -829,6 +906,41 @@ const PurchaseOrderDetail = ({
</div>
</div>
</div>
{purchaseData.po_date &&
!purchaseData.po_date.startsWith('0001') && (
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Tanggal PO
</span>
<div className='ml-3 flex items-center gap-1'>
<span className='text-gray-900'>
: {formatDate(purchaseData.po_date, 'DD MMM YYYY')}
</span>
<RequirePermission permissions='lti.purchase.update'>
<Button
type='button'
variant='ghost'
color='warning'
className='p-1 min-h-0 h-auto'
onClick={() => {
setEditPoDate(
formatDate(purchaseData.po_date, 'YYYY-MM-DD')
);
editPoDateModal.openModal();
}}
>
<Icon
icon='material-symbols:edit-outline'
width={14}
height={14}
/>
</Button>
</RequirePermission>
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -1016,27 +1128,79 @@ const PurchaseOrderDetail = ({
</div>
</Card>
{/* Confirmation Modal with Notes */}
<ConfirmationModalWithNotes
{/* Manager Approval Modal */}
<Modal
ref={confirmationModalWithNotes.ref}
type='success'
text='Apakah Anda yakin ingin melanjutkan approval ini?'
placeholder='(Opsional) Tambahkan catatan untuk approval ini...'
rows={4}
closeOnBackdrop
primaryButton={{
text: 'Ya, Lanjutkan',
color: 'success',
onClick: managerApprovalHandler,
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
secondaryButton={{
text: 'Batal',
onClick: () => {
setApprovalNotes('');
confirmationModalWithNotes.closeModal();
},
}}
/>
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Konfirmasi Approval Manager
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<p className='text-sm text-base-content/70'>
Apakah Anda yakin ingin melanjutkan approval ini?
</p>
<DateInput
name='manager_approval_po_date'
label='Tanggal PO'
value={managerApprovalPoDate}
onChange={(e) => setManagerApprovalPoDate(e.target.value)}
isNestedModal
/>
<TextArea
name='manager_approval_notes'
label='Catatan (Opsional)'
placeholder='Tambahkan catatan untuk approval ini...'
value={managerApprovalNotes}
onChange={(e) => setManagerApprovalNotes(e.target.value)}
rows={4}
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={managerApprovalHandler}
className='px-3 py-2.5'
>
Ya, Lanjutkan
</Button>
</div>
</div>
</Modal>
{/* Staff Approval Modal */}
<Modal
@@ -1112,6 +1276,66 @@ const PurchaseOrderDetail = ({
/>
</Modal>
{/* Edit PO Date Modal */}
<Modal
ref={editPoDateModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-sm rounded-lg p-0',
}}
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Edit Tanggal PO
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='edit_po_date'
label='Tanggal PO'
value={editPoDate}
onChange={(e) => setEditPoDate(e.target.value)}
isNestedModal
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='primary'
onClick={updatePoDateHandler}
className='px-3 py-2.5'
disabled={!editPoDate}
>
Simpan
</Button>
</div>
</div>
</Modal>
{/* Staff Rejection Modal */}
<ConfirmationModalWithNotes
ref={staffRejectionModal.ref}
@@ -1,20 +1,38 @@
'use client';
import { useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import Tabs from '@/components/Tabs';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ReportExpenseTab from './tab/ReportExpenseTab';
import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab';
import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab';
const VALID_TAB_IDS = ['operational-expense', 'depreciation'];
const ReportExpenseTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') ?? 'operational-expense';
const activeTabId = VALID_TAB_IDS.includes(tabParam)
? tabParam
: 'operational-expense';
const tabActions = useTabActionsStore((state) => state.tabActions);
const handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [
{
id: '1',
id: 'operational-expense',
label: 'Laporan Biaya Operasional',
content: <ReportExpenseTab tabId={'1'} />,
content: <ReportExpenseTab tabId={'operational-expense'} />,
},
{
id: 'depreciation',
label: 'Laporan Depresiasi',
content: <ReportDepreciationTab tabId={'depreciation'} />,
},
];
@@ -24,7 +42,7 @@ const ReportExpenseTabs = () => {
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onTabChange={handleTabChange}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
@@ -1,27 +1,26 @@
import React from 'react';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ColumnDef } from '@tanstack/react-table';
type ReportExpenseColumn =
| ColumnDef<ReportExpense>
type ReportSkeletonColumn<TData extends object> =
| ColumnDef<TData>
| {
header: string;
columns: Array<{
header: string;
accessorKey?: string;
cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
cell?: (props: { row: { original: TData } }) => React.ReactNode;
}>;
};
const ReportExpenseSkeleton = ({
const ReportExpenseSkeleton = <TData extends object>({
columns,
icon,
title,
subtitle,
}: {
columns: ReportExpenseColumn[];
columns: ReportSkeletonColumn<TData>[];
icon: React.ReactNode;
title: string;
subtitle: string;
@@ -0,0 +1,237 @@
'use client';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import * as yup from 'yup';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { AreaApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock';
export type ReportDepreciationFilterValues = {
area?: OptionType<string>;
location?: OptionType<string>;
projectFlock?: OptionType<string>;
period: string | null;
};
export const ReportDepreciationFilterSchema = yup.object({
area: yup.mixed<OptionType<string>>().optional(),
location: yup.mixed<OptionType<string>>().optional(),
projectFlock: yup.mixed<OptionType<string>>().optional(),
period: yup.string().nullable().required('Periode wajib dipilih'),
});
interface ReportDepreciationFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: Partial<ReportDepreciationFilterValues>;
onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void;
onReset?: () => void;
}
const defaultInitialValues: (
initialValues?: Partial<ReportDepreciationFilterValues>
) => ReportDepreciationFilterValues = (initialValues) => ({
area: undefined,
location: undefined,
projectFlock: undefined,
period: initialValues?.period ?? null,
});
const ReportDepreciationFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: ReportDepreciationFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
const formik = useFormik<ReportDepreciationFilterValues>({
initialValues: { ...defaultInitialValues(initialValues), ...initialValues },
validationSchema: ReportDepreciationFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
});
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: String(formik.values.area?.value ?? ''),
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: String(formik.values.location?.value ?? ''),
}
);
const formikResetHandler = () => {
onReset?.();
formik.resetForm({ values: defaultInitialValues(initialValues) });
closeModalHandler();
};
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
const area =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('area', area);
formik.setFieldValue('location', undefined);
formik.setFieldValue('projectFlock', undefined);
};
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const location =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('location', location);
formik.setFieldValue('projectFlock', undefined);
};
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
const projectFlock =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('projectFlock', projectFlock);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
}}
>
<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-base-content/10'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={formik.values.area ?? null}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={formik.values.location ?? null}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={formik.values.projectFlock ?? null}
onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isLoading={isLoadingProjectFlockOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<DateInput
label='Periode'
name='period'
placeholder='Pilih Periode'
value={formik.values.period || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.period && !!formik.errors.period}
errorMessage={formik.errors.period}
required
isNestedModal
/>
</div>
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default ReportDepreciationFilterModal;
@@ -0,0 +1,264 @@
'use client';
import React, { useEffect, useMemo } from 'react';
import useSWR from 'swr';
import { ColumnDef } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
import { useModal } from '@/components/Modal';
import ReportDepreciationFilterModal from '@/components/pages/report/expense/tab/ReportDepreciationFilterModal';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import { ReportDepreciation } from '@/types/api/report/report-expense';
import { DepreciationReportApi } from '@/services/api/report/expense-report';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
interface ReportDepreciationTabProps {
tabId: string;
}
const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
area?: OptionType<string>;
location?: OptionType<string>;
projectFlock?: OptionType<string>;
period: string;
}>({
initial: {
area: undefined,
location: undefined,
projectFlock: undefined,
period: formatDate(Date.now(), 'YYYY-MM-DD'),
},
paramMap: {
pageSize: 'limit',
area: 'area_id',
location: 'location_id',
projectFlock: 'project_flock_id',
period: 'period',
},
persist: true,
storeName: 'report-depreciation-table',
});
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
useSWR(
`${DepreciationReportApi.basePath}${getTableFilterQueryString()}`,
DepreciationReportApi.getAllFetcher
);
const depreciations = isResponseSuccess(depreciationsResponse)
? depreciationsResponse.data
: [];
const filterModal = useModal();
const { ref: filterModalRef } = filterModal;
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const depreciationKandangColumns: ColumnDef<
ReportDepreciation['components']['kandang'][0]
>[] = [
{
accessorKey: 'kandang_name',
header: 'Kandang',
},
{
accessorKey: 'house_type',
header: 'Tipe Kandang',
cell: ({ row }) => row.original.house_type.toUpperCase(),
},
{
accessorKey: 'depreciation_percent',
header: 'Persentase Depresiasi',
cell: ({ row }) => row.original.depreciation_percent + '%',
},
{
accessorKey: 'depreciation_value',
header: 'Nilai Depresiasi',
cell: ({ row }) => formatCurrency(row.original.depreciation_value),
},
{
accessorKey: 'depreciation_source',
header: 'Asal Depresiasi',
cell: ({ row }) => row.original.depreciation_source.toUpperCase(),
},
{
accessorKey: 'cutover_date',
header: 'Tanggal Cutover',
cell: ({ row }) => formatDate(row.original.cutover_date, 'DD MMM YYYY'),
},
{
accessorKey: 'origin_date',
header: 'Tanggal Origin',
cell: ({ row }) => formatDate(row.original.origin_date, 'DD MMM YYYY'),
},
];
const tabActionsElement = useMemo(
() => (
<div className='flex flex-row gap-3'>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize']}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
</div>
),
[tableFilterState]
);
useEffect(() => {
setTabActions(tabId, tabActionsElement);
}, [setTabActions, tabActionsElement, tabId]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions, tabId]);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoadingDepreciations && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoadingDepreciations && depreciations.length === 0 && (
<ReportExpenseSkeleton
columns={depreciationKandangColumns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoadingDepreciations && depreciations.length > 0 && (
<>
{depreciations.map((depreciationItem, idx) => (
<Card
key={idx}
title={depreciationItem.farm_name}
subtitle={`Period: ${formatDate(depreciationItem.period, 'DD MMM YYYY')} | Depresiasi Efektif: ${formatNumber(depreciationItem.depreciation_percent_effective, 'en-US', 0, 10)}% | Nilai Depresiasi: ${formatCurrency(depreciationItem.depreciation_value)} | Total Pullet Cost: ${formatCurrency(depreciationItem.pullet_cost_day_n_total, 'IDR', 'id-ID', 0, 10)}`}
className={{
wrapper: 'w-full rounded-lg border-none',
body: 'p-0',
title:
'px-2 py-1.5 font-normal text-sm bg-primary text-white',
subtitle:
'px-2 pb-1.5 bg-primary text-white text-xs font-normal',
collapsible: 'rounded-lg',
}}
variant='bordered'
collapsible={true}
>
<Table
data={depreciationItem.components.kandang}
columns={depreciationKandangColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(depreciationsResponse)
? depreciationsResponse?.meta?.page
: 0
}
totalItems={
isResponseSuccess(depreciationsResponse)
? depreciationsResponse?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoadingDepreciations}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName:
'overflow-x-auto rounded-tr-none rounded-tl-none',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
</Card>
))}
<Pagination
totalItems={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.total_results ?? 0)
: 0
}
itemsPerPage={tableFilterState.pageSize}
currentPage={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.page ?? 0)
: 0
}
onPrevPage={() => setPage(tableFilterState.page - 1)}
onNextPage={() => setPage(tableFilterState.page + 1)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</>
)}
</div>
<ReportDepreciationFilterModal
ref={filterModalRef}
initialValues={tableFilterState}
onReset={resetFilter}
onSubmit={(values) => {
updateFilter('area', values.area, true);
updateFilter('location', values.location, true);
updateFilter('projectFlock', values.projectFlock, true);
updateFilter(
'period',
values.period ? formatDate(values.period, 'YYYY-MM-DD') : '',
true
);
}}
/>
</>
);
};
export default ReportDepreciationTab;
@@ -23,8 +23,8 @@ import RealizationStatusBadge from '@/components/pages/expense/RealizationStatus
import Table from '@/components/Table';
import { formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ReportExpenseApi } from '@/services/api/report';
import { isResponseSuccess } from '@/lib/api-helper';
import { ReportExpenseApi } from '@/services/api/report/expense-report';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import Modal, { useModal } from '@/components/Modal';
import Pagination from '@/components/Pagination';
@@ -39,7 +39,7 @@ import {
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef } from '@tanstack/react-table';
import { ColumnDef, SortingState, Updater } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter';
@@ -73,6 +73,25 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SORTING STATE =====
const [sortBy, setSortBy] = useState('');
const [orderBy, setOrderBy] = useState('');
const sorting: SortingState = sortBy
? [{ id: sortBy, desc: orderBy === 'desc' }]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
setSortBy(next[0].id);
setOrderBy(next[0].desc ? 'desc' : 'asc');
} else {
setSortBy('');
setOrderBy('');
}
};
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal();
@@ -126,8 +145,49 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
});
handleFilterModalOpenRef.current = () => {
const restoredLocation = filterParams.location_id
? locationOptions.find(
(opt) => String(opt.value) === filterParams.location_id
) || {
value: filterParams.location_id,
label: filterParams.location_id,
}
: null;
const restoredSupplier = filterParams.supplier_id
? supplierOptions.find(
(opt) => String(opt.value) === filterParams.supplier_id
) || {
value: filterParams.supplier_id,
label: filterParams.supplier_id,
}
: null;
const restoredKandang = filterParams.kandang_id
? projectFlockKandangOptions.find(
(opt) => String(opt.value) === filterParams.kandang_id
) || { value: filterParams.kandang_id, label: filterParams.kandang_id }
: null;
const restoredNonstock = filterParams.nonstock_id
? nonstockOptions.find(
(opt) => String(opt.value) === filterParams.nonstock_id
) || {
value: filterParams.nonstock_id,
label: filterParams.nonstock_id,
}
: null;
const restoredCategory = filterParams.category
? categoryOptions.find((opt) => opt.value === filterParams.category) ||
null
: null;
formik.setValues({
location_id: restoredLocation,
supplier_id: restoredSupplier,
kandang_id: restoredKandang,
nonstock_id: restoredNonstock,
realization_date: filterParams.realization_date || null,
category: restoredCategory,
});
filterModal.openModal();
formik.validateForm();
};
// ===== OPTIONS =====
@@ -189,26 +249,49 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
[formik.values.category]
);
const buildReportExpenseQueryString = useCallback(
(extraParams?: Record<string, string>) => {
const params = new URLSearchParams();
if (filterParams.location_id) {
params.append('location_id', filterParams.location_id);
}
if (filterParams.supplier_id) {
params.append('supplier_id', filterParams.supplier_id);
}
if (filterParams.kandang_id) {
params.append('project_flock_kandang_id', filterParams.kandang_id);
}
if (filterParams.nonstock_id) {
params.append('nonstock_id', filterParams.nonstock_id);
}
if (filterParams.realization_date) {
params.append('realization_date', filterParams.realization_date);
}
if (filterParams.category) {
params.append('category', filterParams.category);
}
if (sortBy) params.append('sort_by', sortBy);
if (orderBy) params.append('sort_order', orderBy);
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
params.set(key, value);
});
return params.toString();
},
[filterParams, sortBy, orderBy]
);
// ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR(
() => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('project_flock_kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category)
params.append('category', filterParams.category);
params.append('page', String(page));
params.append('limit', String(pageSize));
const queryString = buildReportExpenseQueryString({
page: String(page),
limit: String(pageSize),
});
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
return [`${ReportExpenseApi.basePath}?${queryString}`];
},
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
);
@@ -233,47 +316,31 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null
> => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category) params.append('category', filterParams.category);
params.append('limit', '100');
params.append('page', '1');
const queryString = buildReportExpenseQueryString({
page: '1',
limit: '100',
});
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
`${ReportExpenseApi.basePath}?${params.toString()}`
`${ReportExpenseApi.basePath}?${queryString}`
);
return isResponseSuccess(response) ? response.data : null;
}, [filterParams]);
}, [buildReportExpenseQueryString]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await reportExpenseExport();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateReportExpenseExcel(allDataForExport);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
);
} finally {
setIsExcelExportLoading(false);
}
}, [reportExpenseExport]);
}, [buildReportExpenseQueryString]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -397,19 +464,23 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
return [
{
header: 'No',
enableSorting: false,
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
},
{
header: 'No. PO',
accessorKey: 'po_number',
enableSorting: true,
},
{
header: 'No. Referensi',
accessorKey: 'reference_number',
enableSorting: true,
},
{
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
enableSorting: true,
cell: ({ row }) => {
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
},
@@ -417,6 +488,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{
header: 'Tanggal Transaksi',
accessorKey: 'transaction_date',
enableSorting: true,
cell: ({ row }) => {
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
},
@@ -424,21 +496,30 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{
header: 'Kategori',
accessorKey: 'category',
enableSorting: true,
},
{
header: 'Produk',
accessorKey: 'product',
enableSorting: true,
accessorFn: (row) => row.pengajuan?.nonstock?.name,
},
{
header: 'Supplier',
accessorKey: 'supplier',
enableSorting: true,
accessorFn: (row) => row.supplier?.name,
},
{
header: 'Lokasi',
accessorKey: 'location',
enableSorting: true,
accessorFn: (row) => row.kandang?.location?.name,
},
{
header: 'Kandang',
accessorKey: 'kandang',
enableSorting: true,
accessorFn: (row) => row.kandang?.name,
},
{
@@ -446,23 +527,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [
{
header: 'Qty',
id: 'qty_pengajuan',
accessorFn: (row) => row.pengajuan?.qty,
accessorKey: 'qty_pengajuan',
cell: ({ row }) =>
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
id: 'harga_pengajuan',
accessorFn: (row) => row.pengajuan?.price,
accessorKey: 'price_pengajuan',
cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0),
},
{
header: 'Total',
id: 'total_pengajuan',
accessorFn: (row) =>
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
accessorKey: 'total_pengajuan',
cell: ({ row }) => {
const total =
(row.original.pengajuan?.qty || 0) *
@@ -477,23 +554,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [
{
header: 'Qty',
id: 'qty_realisasi',
accessorFn: (row) => row.realisasi?.qty,
accessorKey: 'qty_realisasi',
cell: ({ row }) =>
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
id: 'harga_realisasi',
accessorFn: (row) => row.realisasi?.price,
accessorKey: 'price_realisasi',
cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0),
},
{
header: 'Total',
id: 'total_realisasi',
accessorFn: (row) =>
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
accessorKey: 'total_realisasi',
cell: ({ row }) => {
const total =
(row.original.realisasi?.qty || 0) *
@@ -504,6 +577,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
],
},
{
id: 'realization_status',
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge
@@ -512,6 +586,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
),
},
{
id: 'bop_status',
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
@@ -556,6 +631,9 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
@@ -1,25 +1,47 @@
'use client';
import { useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
import BalanceMonitoringTab from '@/components/pages/report/finance/tab/BalanceMonitoringTab';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
const VALID_TAB_IDS = [
'debt-supplier',
'customer-payment',
'balance-monitoring',
];
const FinanceTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') ?? 'debt-supplier';
const activeTabId = VALID_TAB_IDS.includes(tabParam)
? tabParam
: 'debt-supplier';
const tabActions = useTabActionsStore((state) => state.tabActions);
const handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [
{
id: '1',
id: 'debt-supplier',
label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab tabId={'1'} />,
content: <DebtSupplierTab tabId={'debt-supplier'} />,
},
{
id: '2',
id: 'customer-payment',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab tabId={'2'} />,
content: <CustomerPaymentTab tabId={'customer-payment'} />,
},
{
id: 'balance-monitoring',
label: 'Monitoring Saldo',
content: <BalanceMonitoringTab tabId={'balance-monitoring'} />,
},
];
@@ -29,7 +51,7 @@ const FinanceTabs = () => {
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onTabChange={handleTabChange}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
@@ -0,0 +1,677 @@
'use client';
import { useState, useMemo, useEffect, useCallback } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { ColumnDef } from '@tanstack/react-table';
import { AxiosError } from 'axios';
import { FinanceApi } from '@/services/api/report/finance-report';
import { CustomerApi } from '@/services/api/master-data';
import { UserApi } from '@/services/api/user';
import { useSelect, OptionType } from '@/components/input/SelectInput';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
import { CustomerPaymentRow } from '@/types/api/report/customer-payment';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import DateInput from '@/components/input/DateInput';
import Table from '@/components/Table';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
interface BalanceMonitoringTabProps {
tabId: string;
}
const filterByOptions: OptionType<string>[] = [
{ label: 'Tanggal Penjualan (SO Date)', value: 'sold_at' },
{ label: 'Tanggal Realisasi (Delivery Date)', value: 'realized_at' },
];
const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customers: OptionType<number>[];
salesPersons: OptionType<number>[];
filterBy?: OptionType<string>;
sort_by: string;
order_by: string;
}>({
initial: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customers: 'customer_ids',
salesPersons: 'sales_ids',
filterBy: 'filter_by',
sort_by: 'sort_by',
order_by: 'sort_order',
},
persist: true,
storeName: 'balance-monitoring-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);
// }
// };
const {
options: customerOptions,
setInputValue: setCustomerInput,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: salesOptions,
setInputValue: setSalesInput,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const formik = useFormik({
initialValues: {
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
},
onSubmit: (values) => {
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('customers', values.customers, true);
updateFilter('salesPersons', values.salesPersons, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const { data: balanceMonitoringsResponse, isLoading } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
const balanceMonitorings: BalanceMonitoringRow[] = isResponseSuccess(
balanceMonitoringsResponse
)
? ((balanceMonitoringsResponse.data as BalanceMonitoringRow[]) ?? [])
: [];
const meta =
isResponseSuccess(balanceMonitoringsResponse) &&
balanceMonitoringsResponse.meta
? balanceMonitoringsResponse.meta
: null;
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
const sales_ids =
tableFilterState.salesPersons.length > 0
? tableFilterState.salesPersons.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportBalanceMonitoringToExcel(
customer_ids,
sales_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [tableFilterState]);
// Inject tab actions directly — no nested component, no remount cycle
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
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'
isLoading={isExcelExportLoading}
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={handleExportExcel}
isLoading={isExcelExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
</Dropdown>
</div>
);
}, [
tabId,
setTabActions,
tableFilterState,
filterModal.openModal,
isExcelExportLoading,
handleExportExcel,
]);
useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const columns = useMemo(
(): ColumnDef<BalanceMonitoringRow>[] => [
{
header: 'No',
enableSorting: false,
cell: (props) =>
(tableFilterState.page - 1) * tableFilterState.pageSize +
props.row.index +
1,
},
{
header: 'Customer',
accessorKey: 'customer.name',
enableSorting: true,
id: 'customer_name',
cell: ({ row }) => row.original.customer.name,
},
{
header: 'Saldo Awal',
accessorKey: 'saldo_awal',
id: 'saldo_awal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.saldo_awal)}
</div>
),
},
{
header: 'Penjualan Ayam',
columns: [
{
header: 'Ekor',
accessorKey: 'penjualan_ayam.ekor',
id: 'penjualan_ayam_ekor',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.ekor)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_ayam.kg',
id: 'penjualan_ayam_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_ayam.nominal',
id: 'penjualan_ayam_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_ayam.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Telur',
columns: [
{
header: 'Butir',
accessorKey: 'penjualan_telur.butir',
id: 'penjualan_telur_butir',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.butir)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_telur.kg',
id: 'penjualan_telur_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_telur.nominal',
id: 'penjualan_telur_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_telur.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Trading',
accessorKey: 'penjualan_trading.nominal',
id: 'penjualan_trading',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_trading.nominal)}
</div>
),
},
{
header: 'Pembayaran',
accessorKey: 'pembayaran',
id: 'pembayaran',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.pembayaran)}
</div>
),
},
{
header: 'Aging',
accessorKey: 'aging',
id: 'aging',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging)} hari
</div>
),
},
{
header: 'Aging Rata-Rata',
accessorKey: 'aging_rata_rata',
id: 'aging_rata_rata',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging_rata_rata)} hari
</div>
),
},
{
header: 'Saldo Akhir',
accessorKey: 'saldo_akhir',
id: 'saldo_akhir',
enableSorting: true,
cell: ({ row }) => (
<div
className={`text-right font-semibold ${row.original.saldo_akhir < 0 ? 'text-error' : ''}`}
>
{formatCurrency(row.original.saldo_akhir)}
</div>
),
},
],
[tableFilterState.page, tableFilterState.pageSize]
);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoading && balanceMonitorings.length === 0 && (
<CustomerSupplierSkeleton
columns={columns as unknown as ColumnDef<CustomerPaymentRow>[]}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoading && balanceMonitorings.length > 0 && (
<>
<div className='w-full overflow-x-auto'>
<Table
data={balanceMonitorings}
columns={columns}
pageSize={tableFilterState.pageSize || 10}
page={tableFilterState.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
// sorting={sorting}
// setSorting={handleSortingChange}
// manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</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',
}}
>
{/* Modal Header */}
<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={formikResetHandler}>
<div className='p-4 flex flex-col gap-3'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={formik.values.customers}
onChange={(val) =>
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
}
onInputChange={setCustomerInput}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={formik.values.salesPersons}
onChange={(val) =>
formik.setFieldValue(
'salesPersons',
Array.isArray(val) ? val : []
)
}
onInputChange={setSalesInput}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<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={hasDateError}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default BalanceMonitoringTab;
@@ -1,14 +1,17 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { AxiosError } from 'axios';
import Card from '@/components/Card';
import StatusBadge from '@/components/helper/StatusBadge';
import { useSelect } from '@/components/input/SelectInput';
import { useSelect, OptionType } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import {
@@ -27,55 +30,70 @@ import Dropdown from '@/components/Dropdown';
import Modal, { useModal } from '@/components/Modal';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import {
CustomerPaymentFilterSchema,
CustomerPaymentFilterType,
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Pagination from '@/components/Pagination';
import { useTableFilter } from '@/services/hooks/useTableFilter';
interface CustomerPaymentTabProps {
tabId: string;
}
interface FilterParams {
customer_ids?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
}
const dataTypeOptions: OptionType<string>[] = [
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
{ value: 'realization_date', label: 'Tanggal Realisasi' },
];
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal();
const dataTypeOptions = useMemo(
() => [
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
{ value: 'realization_date', label: 'Tanggal Realisasi' },
],
[]
);
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customers: OptionType<number>[];
filterBy?: OptionType<string>;
}>({
initial: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customers: 'customer_ids',
filterBy: 'filter_by',
},
persist: true,
storeName: 'customer-payment-report-table',
});
const {
options: customerOptions,
@@ -85,223 +103,188 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// ===== FORMIK SETUP =====
const formik = useFormik<CustomerPaymentFilterType>({
const formik = useFormik({
initialValues: {
start_date: null,
end_date: null,
customer_ids: null,
filter_by: null,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
filterBy: tableFilterState.filterBy,
},
validationSchema: CustomerPaymentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
start_date: values.start_date || undefined,
end_date: values.end_date || undefined,
customer_ids: values.customer_ids || undefined,
filter_by: values.filter_by || undefined,
});
filterModal.closeModal();
setCurrentPage(1);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setCurrentPage(1);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
onSubmit: (values) => {
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('customers', values.customers, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
handleFilterModalOpenRef.current = () => {
filterModal.openModal();
formik.validateForm();
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
const getPaymentStatusBadgeColor = (notes: string): Color => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'primary';
}
if (normalizedValue.includes('belum')) {
return 'warning';
}
if (normalizedValue === 'lunas') return 'primary';
if (normalizedValue.includes('belum')) return 'warning';
return 'neutral';
};
// ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value || null);
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
}
},
[formik, dateErrorShown]
);
const handleEndDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value || null);
if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
},
[formik, dateErrorShown]
);
}
// ===== FILTER HELPERS =====
const customerIdsValue = useMemo(() => {
if (!formik.values.customer_ids) return [];
return customerOptions.filter((opt) =>
formik.values.customer_ids?.split(',').includes(String(opt.value))
);
}, [formik.values.customer_ids, customerOptions]);
const filterByValue = useMemo(() => {
if (!formik.values.filter_by) return null;
return (
dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) ||
null
);
}, [formik.values.filter_by, dataTypeOptions]);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
() => {
const params = {
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
return ['customer-payment-report', params];
},
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
)
const { data: customerPayment, isLoading } = useSWR<
BaseApiResponse<CustomerPaymentReport>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/customer-payment${getTableFilterQueryString()}`,
httpClientFetcher
);
const data: CustomerPaymentReport[] = useMemo(
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [],
[customerPayment]
);
const data: CustomerPaymentReport[] = isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [];
const meta =
isResponseSuccess(customerPayment) && customerPayment.meta
? customerPayment.meta
: null;
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
> => {
const params = {
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
limit: 100,
page: 1,
};
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
const filter_by = tableFilterState.filterBy?.value as
| 'trans_date'
| 'realization_date'
| undefined;
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
customer_ids,
filter_by,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined,
1,
100
);
return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[])
: null;
}, [filterParams]);
}, [tableFilterState]);
// ===== EXPORT HANDLERS =====
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportCustomerPaymentToExcelGeneral(
customer_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [tableFilterState]);
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await customerPaymentExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateCustomerPaymentExcel({ data: allDataForExport });
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportCustomerPaymentToExcelCustomerPerSheet(
customer_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [customerPaymentExport]);
}, [tableFilterState]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -317,22 +300,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return;
}
const customerName = filterParams.customer_ids
? customerOptions
.filter((opt) =>
filterParams.customer_ids?.split(',').includes(String(opt.value))
)
.map((opt) => opt.label)
.join(', ') || 'Semua Customer'
: 'Semua Customer';
const customerName =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => o.label).join(', ')
: 'Semua Customer';
await generateCustomerPaymentPDF({
data: allDataForExport,
params: {
customer_name: customerName,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
filter_by: filterParams.filter_by as
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
filter_by: tableFilterState.filterBy?.value as
| 'trans_date'
| 'realization_date'
| undefined,
@@ -344,106 +323,103 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} finally {
setIsPdfExportLoading(false);
}
}, [customerPaymentExport, filterParams, customerOptions]);
}, [customerPaymentExport, tableFilterState]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
// ===== TAB ACTIONS =====
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={filterParams}
fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()}
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
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'
isLoading={isAnyExportLoading}
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>
}
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
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={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
<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={handleExportExcel}
isLoading={isExcelExportLoading}
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 - Customer Per Sheet
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportExcelGeneral}
isLoading={isExcelGeneralExportLoading}
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 - General
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading,
handleExportExcel,
handleExportExcelGeneral,
handleExportPdf,
isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading,
filterParams,
]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const getTableColumns = (
summary: CustomerPaymentSummary
@@ -650,11 +626,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
enableSorting: false,
cell: (props) => {
const value = props.row.original.status;
if (!value) {
return '-';
}
if (!value) return '-';
return (
<StatusBadge
color={getPaymentStatusBadgeColor(value)}
@@ -693,7 +665,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return (
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
@@ -717,6 +688,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
/>
)}
{!isLoading && data.length > 0 && meta && (
<div className='w-full ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
{!isLoading &&
data.length > 0 &&
data.map((customerReport) => {
@@ -811,6 +803,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
</Card>
);
})}
{!isLoading && data.length > 0 && meta && (
<div className='mt-5 px-3'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</div>
{/* Filter Modal */}
@@ -835,7 +848,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
@@ -845,29 +858,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<DateInput
name='start_date'
value={formik.values.start_date || ''}
errorMessage={formik.errors.start_date}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={
formik.touched.start_date &&
Boolean(formik.errors.start_date)
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
errorMessage={formik.errors.end_date}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={
(formik.touched.end_date &&
Boolean(formik.errors.end_date)) ||
hasDateError
}
isError={hasDateError}
/>
</div>
</div>
@@ -876,15 +878,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={customerIdsValue}
onChange={(val) => {
formik.setFieldValue(
'customer_ids',
Array.isArray(val) && val.length > 0
? val.map((v: OptionType) => String(v.value)).join(',')
: null
);
}}
value={formik.values.customers}
onChange={(val) =>
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
isClearable
@@ -896,14 +893,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={filterByValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue('filter_by', val?.value || null);
}
}}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
className={{ wrapper: 'w-full' }}
isClearable={true}
isClearable
/>
</div>
@@ -919,7 +917,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError || !formik.isValid || formik.isSubmitting}
disabled={hasDateError}
>
Apply Filter
</Button>
@@ -1,6 +1,7 @@
import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import Pagination from '@/components/Pagination';
import DateInput from '@/components/input/DateInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
@@ -8,24 +9,15 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data';
import {
DebtRow,
DebtSupplier,
DebtSupplierFilter,
} from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
import { useFormik } from 'formik';
import {
DebtSupplierFilterSchema,
DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
@@ -34,6 +26,10 @@ import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import StatusBadge from '@/components/helper/StatusBadge';
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios';
const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error',
@@ -51,7 +47,6 @@ const getPillBadge = (
statusText: string,
type: 'due' | 'payment' = 'payment'
) => {
// Get color based on type
const color =
type === 'due'
? dueStatus[statusText] || 'neutral'
@@ -68,6 +63,11 @@ const getPillBadge = (
);
};
const dataTypeOptions: OptionType<string>[] = [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
];
interface DebtSupplierTabProps {
tabId: string;
}
@@ -76,24 +76,50 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
suppliers: OptionType<number>[];
filterBy?: OptionType<string>;
}>({
initial: {
start_date: '',
end_date: '',
suppliers: [],
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
suppliers: 'supplier_ids',
filterBy: 'filter_by',
},
persist: true,
storeName: 'debt-supplier-report-table',
});
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
@@ -101,140 +127,180 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
// ===== FORMIK SETUP =====
const formik = useFormik<DebtSupplierFilterType>({
const formik = useFormik({
initialValues: {
startDate: null,
endDate: null,
supplierIds: null,
filterBy: null,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
suppliers: tableFilterState.suppliers,
filterBy: tableFilterState.filterBy,
},
validationSchema: DebtSupplierFilterSchema,
onSubmit: (values) => {
setFilterParams({
start_date: values.startDate?.toString() || undefined,
end_date: values.endDate?.toString() || undefined,
supplier_ids:
values.supplierIds?.map((v) => String(v.value)).join(',') ||
undefined,
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal();
// setIsSubmitted(true);
},
onReset: () => {
setFilterParams({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
// setIsSubmitted(false);
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('suppliers', values.suppliers, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
handleFilterModalOpenRef.current = () => {
filterModal.openModal();
formik.validateForm();
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
suppliers: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
// ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
// ===== DATA FETCHING =====
const { data: debtSupplier, isLoading } = useSWR(
() => {
const params = {
supplier_ids: filterParams.supplier_ids,
filter_by: filterParams.filter_by,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
};
return ['debt-supplier-report', params];
},
([, params]) =>
DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date
)
const { data: debtSupplierResponse, isLoading } = useSWR<
BaseApiResponse<DebtSupplier[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${DebtSupplierApi.basePath}/debt-supplier${getTableFilterQueryString()}`,
httpClientFetcher
);
const data: DebtSupplier[] = useMemo(
() =>
isResponseSuccess(debtSupplier)
? (debtSupplier?.data as unknown as DebtSupplier[]) || []
: [],
[debtSupplier]
);
const data: DebtSupplier[] = isResponseSuccess(debtSupplierResponse)
? ((debtSupplierResponse?.data as unknown as DebtSupplier[]) ?? [])
: [];
const meta =
isResponseSuccess(debtSupplierResponse) && debtSupplierResponse.meta
? debtSupplierResponse.meta
: null;
// ===== EXPORT DATA FETCHER =====
const debtSupplierExport = useCallback(async (): Promise<
DebtSupplier[] | null
> => {
const params = {
supplier_ids:
formik.values.supplierIds && formik.values.supplierIds.length > 0
? formik.values.supplierIds.map((v) => String(v.value)).join(',')
: undefined,
filter_by: formik.values.filterBy?.value?.toString() || undefined,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
date_type: formik.values.filterBy
? formik.values.filterBy.value
: undefined,
limit: 100,
page: 1,
};
const supplier_ids =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
: undefined;
const response = await DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date
supplier_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined,
1,
100
);
return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[])
: null;
}, [
formik.values.supplierIds,
formik.values.startDate,
formik.values.endDate,
formik.values.filterBy,
]);
}, [tableFilterState]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await debtSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
generateDebtSupplierExcel({ data: allDataForExport });
const supplier_ids =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
: undefined;
await DebtSupplierApi.exportToExcelSupplierPerSheet(
supplier_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [debtSupplierExport]);
}, [tableFilterState]);
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
const supplier_ids =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
: undefined;
await DebtSupplierApi.exportToExcelGeneral(
supplier_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [tableFilterState]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -250,15 +316,18 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
return;
}
const supplierName =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => o.label).join(', ')
: undefined;
await generateDebtSupplierPDF({
data: allDataForExport,
params: {
supplier_name: formik.values.supplierIds
?.map((v) => v.label)
.join(', '),
filter_by: formik.values.filterBy?.label,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
supplier_name: supplierName,
filter_by: tableFilterState.filterBy?.label,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
@@ -267,129 +336,103 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
} finally {
setIsPdfExportLoading(false);
}
}, [
debtSupplierExport,
formik.values.supplierIds,
formik.values.filterBy,
formik.values.startDate,
formik.values.endDate,
]);
}, [debtSupplierExport, tableFilterState]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
// ===== TAB ACTIONS =====
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
suppliers: tableFilterState.suppliers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={filterParams}
fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()}
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
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'
isLoading={isAnyExportLoading}
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>
}
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
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={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
<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={handleExportExcel}
isLoading={isExcelExportLoading}
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 - Supplier Per Sheet
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportExcelGeneral}
isLoading={isExcelGeneralExportLoading}
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 - General
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
filterParams,
setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading,
handleExportExcel,
handleExportExcelGeneral,
handleExportPdf,
isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading,
]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
{
@@ -604,9 +647,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
},
},
];
return (
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
@@ -630,6 +673,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
/>
)}
{!isLoading && data.length > 0 && meta && (
<div className='w-full ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
{!isLoading &&
data.length > 0 &&
data.map((supplierReport) => {
@@ -717,6 +781,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</Card>
);
})}
{!isLoading && data.length > 0 && meta && (
<div className='mt-5 px-3'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</div>
{/* Filter Modal */}
@@ -727,23 +812,23 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* Modal Header */}
<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'
type='button'
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>
{/* Modal Header */}
<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'
type='button'
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={formikResetHandler}>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div>
@@ -752,153 +837,68 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='startDate'
value={formik.values.startDate || ''}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('startDate', value || null);
if (value && formik.values.endDate) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.endDate);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isError={
formik.touched.startDate && !!formik.errors.startDate
}
errorMessage={formik.errors.startDate}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='endDate'
value={formik.values.endDate || ''}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('endDate', value || null);
if (value && formik.values.startDate) {
const startDateObj = new Date(formik.values.startDate);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isError={
(formik.touched.endDate && !!formik.errors.endDate) ||
hasDateError
}
errorMessage={formik.errors.endDate}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<div>
<SelectInputCheckbox
label='Supplier'
placeholder='Pilih Supplier'
isMulti
options={supplierOptions}
value={
(formik.values.supplierIds as
| { value: number; label: string }
| { value: number; label: string }[]
| null
| undefined) || []
}
onChange={(val) => {
formik.setFieldValue(
'supplierIds',
Array.isArray(val) ? val : val ? [val] : null
);
}}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable
className={{ wrapper: 'w-full' }}
isError={
formik.touched.supplierIds && !!formik.errors.supplierIds
}
errorMessage={formik.errors.supplierIds as string}
/>
</div>
<SelectInputCheckbox
label='Supplier'
placeholder='Pilih Supplier'
options={supplierOptions}
value={formik.values.suppliers}
onChange={(val) =>
formik.setFieldValue('suppliers', Array.isArray(val) ? val : [])
}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable
className={{ wrapper: 'w-full' }}
/>
<div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={
(formik.values.filterBy as
| { value: string; label: string }
| { value: string; label: string }[]
| null
| undefined) || null
}
onChange={(val) => {
formik.setFieldValue(
'filterBy',
val ? (val as OptionType) : null
);
}}
className={{ wrapper: 'w-full' }}
isClearable
isError={formik.touched.filterBy && !!formik.errors.filterBy}
errorMessage={formik.errors.filterBy as string}
/>
</div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
className={{ wrapper: 'w-full' }}
isClearable
/>
</div>
{/* Action Buttons */}
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
variant='soft'
color='none'
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'
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
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError}
>
Apply Filter
</Button>
@@ -16,7 +16,6 @@ import {
LogisticPurchasePerSupplierReport,
LogisticPurchasePerSupplierSummary,
} from '@/types/api/report/logistic-stock';
import { generatePurchasesPerSupplierExcel } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportXLSX';
import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
@@ -53,7 +52,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
@@ -156,8 +158,17 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
});
handleFilterModalOpenRef.current = () => {
formik.setValues({
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
area_ids: filterParams.area_id || null,
supplier_ids: filterParams.supplier_id || null,
product_ids: filterParams.product_id || null,
product_category_ids: filterParams.product_category_id || null,
filter_by: filterParams.filter_by || null,
sort_by: filterParams.sort_by || null,
});
filterModal.openModal();
formik.validateForm();
};
const { setFieldValue } = formik;
@@ -351,25 +362,44 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await logisticPurchasePerSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generatePurchasesPerSupplierExcel({ data: allDataForExport });
await LogisticApi.exportToExcelSupplierPerSheet(
filterParams.area_id,
filterParams.supplier_id,
filterParams.product_id,
filterParams.product_category_id,
filterParams.start_date,
filterParams.end_date,
filterParams.sort_by,
filterParams.filter_by
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [logisticPurchasePerSupplierExport]);
}, [filterParams]);
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
await LogisticApi.exportToExcelGeneral(
filterParams.area_id,
filterParams.supplier_id,
filterParams.product_id,
filterParams.product_category_id,
filterParams.start_date,
filterParams.end_date,
filterParams.sort_by,
filterParams.filter_by
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [filterParams]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -514,7 +544,17 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
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
Export to Excel - Supplier Per Sheet
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportExcelGeneral}
isLoading={isExcelGeneralExportLoading}
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 - General
</Button>
<Button
variant='ghost'
@@ -544,8 +584,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
filterParams,
isAnyExportLoading,
handleExportExcel,
handleExportExcelGeneral,
handleExportPdf,
isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading,
]);
@@ -1,6 +1,8 @@
import * as yup from 'yup';
export type DailyMarketingReportFilterType = {
page?: number;
pageSize?: number;
search: string | null;
area_id: string | null;
location_id: string | null;
@@ -14,6 +16,8 @@ export type DailyMarketingReportFilterType = {
};
export const DailyMarketingReportFilterSchema = yup.object({
page: yup.number().nullable(),
pageSize: yup.number().nullable(),
search: yup.string().nullable(),
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
@@ -1,6 +1,8 @@
import * as yup from 'yup';
export type HppPerKandangFilterType = {
page?: number;
pageSize?: number;
area_id: string | null;
location_id: string | null;
kandang_id: string | null;
@@ -12,6 +14,8 @@ export type HppPerKandangFilterType = {
};
export const HppPerKandangFilterSchema = yup.object({
page: yup.number().nullable(),
pageSize: yup.number().nullable(),
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
kandang_id: yup.string().nullable(),
@@ -17,16 +17,10 @@ import {
formatVechicleNumber,
formatTitleCase,
} from '@/lib/helper';
import {
DailyMarketingRow,
DailyMarketingReportResponse,
} from '@/types/api/report/marketing';
import { DailyMarketingRow } from '@/types/api/report/marketing';
import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
@@ -39,8 +33,6 @@ import Modal, { useModal } from '@/components/Modal';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
import { useEffect as useEffectHook } from 'react';
import { httpClient } from '@/services/http/client';
import { isResponseError } from '@/lib/api-helper';
import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
@@ -53,6 +45,8 @@ interface DailyMarketingTabProps {
}
interface FilterParams {
page?: number;
pageSize?: number;
area_id?: string;
location_id?: string;
warehouse_id?: string;
@@ -116,6 +110,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
// ===== FORMIK SETUP =====
const formik = useFormik<DailyMarketingReportFilterType>({
initialValues: {
page: 1,
pageSize: 10,
search: null,
area_id: null,
location_id: null,
@@ -130,6 +126,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
validationSchema: DailyMarketingReportFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
page: values.page || undefined,
pageSize: values.pageSize || undefined,
area_id: values.area_id || undefined,
location_id: values.location_id || undefined,
warehouse_id: values.warehouse_id || undefined,
@@ -150,8 +148,21 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
});
handleFilterModalOpenRef.current = () => {
formik.setValues({
page: formik.values.page,
pageSize: formik.values.pageSize,
search: formik.values.search,
area_id: filterParams.area_id || null,
location_id: filterParams.location_id || null,
warehouse_id: filterParams.warehouse_id || null,
customer_id: filterParams.customer_id || null,
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
filter_by: filterParams.filter_by || null,
marketing_type: filterParams.marketing_type || null,
sort_by: filterParams.sort_by || null,
});
filterModal.openModal();
formik.validateForm();
};
// ===== SEARCH CHANGE HANDLER =====
@@ -222,6 +233,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.page) params.set('page', String(filterParams.page));
if (filterParams.pageSize)
params.set('limit', String(filterParams.pageSize));
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
@@ -262,67 +276,30 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
[dailyMarketings]
);
// ===== EXPORT DATA FETCHER =====
const dailyMarketingsExport = useCallback(async (): Promise<
DailyMarketingRow[] | null
> => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
params.set('limit', '9999999');
const queryString = `?${params.toString()}`;
try {
const response = await httpClient<DailyMarketingReportResponse>(
`${MarketingReportApi.basePath}${queryString}`
);
if (isResponseError(response)) {
return null;
}
return response.data || [];
} catch {
return null;
}
}, [filterParams, searchValue]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await dailyMarketingsExport();
const params = new URLSearchParams();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
const period =
filterParams.start_date && filterParams.end_date
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
: undefined;
await generateDailyMarketingExcel({
data: allDataForExport,
summaryTotal: summaryTotal,
period: period,
});
await MarketingReportApi.exportDailyMarketingToExcel(params.toString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
@@ -330,34 +307,39 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, dailyMarketingsExport, summaryTotal]);
}, [filterParams, searchValue]);
const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await dailyMarketingsExport();
const params = new URLSearchParams();
if (!allDataForExport || allDataForExport.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
const dailyMarketingReportPdfBlob = await pdf(
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
).toBlob();
await MarketingReportApi.exportDailyMarketingToPDF(params.toString());
const dailyMarketingReportPdfUrl = URL.createObjectURL(
dailyMarketingReportPdfBlob
);
window.open(dailyMarketingReportPdfUrl, '_blank');
toast.success('PDF berhasil dibuat.');
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [dailyMarketingsExport, summaryTotal]);
}, [filterParams, searchValue]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
@@ -572,7 +554,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'qty',
cell: (props) => formatNumber(props.row.original.qty),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_qty
? formatNumber(summaryTotal.total_qty)
: '-'}
@@ -585,7 +567,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'average_weight_kg',
cell: (props) => formatNumber(props.row.original.average_weight_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.average_weight_kg
? formatNumber(summaryTotal.average_weight_kg)
: '-'}
@@ -598,7 +580,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'total_weight_kg',
cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_weight_kg
? formatNumber(summaryTotal.total_weight_kg)
: '-'}
@@ -611,9 +593,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'sales_price_per_kg',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.average_sales_price
? formatNumber(summaryTotal.average_sales_price)
? formatCurrency(summaryTotal.average_sales_price)
: '-'}
</div>
),
@@ -624,7 +606,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'hpp_price_per_kg',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_hpp_price_per_kg
? formatCurrency(summaryTotal.total_hpp_price_per_kg)
: '-'}
@@ -637,7 +619,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'sales_amount',
cell: (props) => formatCurrency(props.row.original.sales_amount),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
<div className='font-semibold text-gray-900'>
{summaryTotal?.total_sales_amount
? formatCurrency(summaryTotal.total_sales_amount)
: '-'}
@@ -688,6 +670,27 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
<Table
data={data}
columns={getTableColumns()}
pageSize={filterParams.pageSize}
page={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.page
: 0
}
totalItems={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.total_results
: 0
}
onPageChange={(newPage) =>
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
}
onPageSizeChange={(newPageSize) =>
setFilterParams((prevVal) => ({
...prevVal,
pageSize: newPageSize,
}))
}
isLoading={isLoading}
renderFooter={data.length > 0}
className={{
containerClassName: 'w-full mb-0!',
@@ -40,6 +40,8 @@ interface HppPerKandangTabProps {
}
interface FilterParams {
page?: number;
pageSize?: number;
area_id?: string;
location_id?: string;
kandang_id?: string;
@@ -108,6 +110,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
// ===== FORMIK SETUP =====
const formik = useFormik<HppPerKandangFilterType>({
initialValues: {
page: 1,
pageSize: 10,
area_id: null,
location_id: null,
kandang_id: null,
@@ -120,6 +124,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
validationSchema: HppPerKandangFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
page: values.page || undefined,
pageSize: values.pageSize || undefined,
area_id: values.area_id || undefined,
location_id: values.location_id || undefined,
kandang_id: values.kandang_id || undefined,
@@ -146,8 +152,19 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
});
handleFilterModalOpenRef.current = () => {
formik.setValues({
page: formik.values.page,
pageSize: formik.values.pageSize,
area_id: filterParams.area_id || null,
location_id: filterParams.location_id || null,
kandang_id: filterParams.kandang_id || null,
weight_min: filterParams.weight_min || null,
weight_max: filterParams.weight_max || null,
period: filterParams.period || null,
sort_by: filterParams.sort_by || null,
show_unrecorded: filterParams.show_unrecorded ?? false,
});
filterModal.openModal();
formik.validateForm();
};
// ===== WEIGHT CHANGE HANDLERS =====
@@ -257,6 +274,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
period: filterParams.period,
sort_by: filterParams.sort_by,
show_unrecorded: filterParams.show_unrecorded,
page: filterParams.page,
pageSize: filterParams.pageSize,
};
return ['hpp-per-kandang-report', params];
@@ -271,7 +290,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
params.show_unrecorded,
params.page,
params.pageSize
)
);
@@ -321,7 +342,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
params.weight_max,
params.period,
params.sort_by,
params.show_unrecorded
params.show_unrecorded,
params.page,
params.limit
);
return isResponseSuccess(response) ? response.data : null;
@@ -466,6 +489,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
<div className='flex flex-row gap-3'>
<ButtonFilter
values={filterParams}
excludeFields={['page', 'pageSize']}
onClick={() => handleFilterModalOpenRef.current()}
variant='outline'
className='px-3 py-2.5'
@@ -845,6 +869,25 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
<Table
data={data}
columns={getTableColumns()}
pageSize={filterParams.pageSize}
page={
isResponseSuccess(hppPerKandang) ? hppPerKandang?.meta?.page : 0
}
totalItems={
isResponseSuccess(hppPerKandang)
? hppPerKandang?.meta?.total_results
: 0
}
onPageChange={(newPage) =>
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
}
onPageSizeChange={(newPageSize) =>
setFilterParams((prevVal) => ({
...prevVal,
pageSize: newPageSize,
}))
}
isLoading={isLoading}
renderFooter={data.length > 0}
renderCustomRow={renderCustomRow}
className={{
@@ -263,8 +263,43 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
});
handleFilterModalOpenRef.current = () => {
const restoredAreaId = filterParams.area_id
? areaOptions.find(
(opt) => String(opt.value) === filterParams.area_id
) || { value: filterParams.area_id, label: filterParams.area_id }
: null;
const restoredLocationId = filterParams.location_id
? locationOptions.find(
(opt) => String(opt.value) === filterParams.location_id
) || {
value: filterParams.location_id,
label: filterParams.location_id,
}
: null;
const restoredProjectFlockId = filterParams.project_flock_id
? projectFlockOptions.find(
(opt) => String(opt.value) === filterParams.project_flock_id
) || {
value: filterParams.project_flock_id,
label: filterParams.project_flock_id,
}
: null;
const restoredKandangId = filterParams.project_flock_kandang_id
? projectFlockKandangOptions.find(
(opt) => String(opt.value) === filterParams.project_flock_kandang_id
) || {
value: filterParams.project_flock_kandang_id,
label: filterParams.project_flock_kandang_id,
}
: null;
formik.setValues({
area_id: restoredAreaId,
location_id: restoredLocationId,
project_flock_id: restoredProjectFlockId,
kandang_id: restoredKandangId,
});
filterModal.openModal();
formik.validateForm();
};
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
+11 -1
View File
@@ -197,6 +197,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
icon: 'heroicons-outline:folder',
permission: [
'lti.inventory.product_stock.list',
'lti.inventory.stock_log.list',
'lti.inventory.product_warehouses.list',
'lti.inventory.transfer.list',
],
@@ -204,7 +205,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
{
text: 'Stok Produk',
link: '/inventory/product',
permission: ['lti.inventory.product_stock.list'],
permission: [
'lti.inventory.product_stock.list',
'lti.inventory.stock_log.list',
],
},
{
text: 'Penyesuaian Stok',
@@ -236,6 +240,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
'lti.master.uoms.list',
'lti.master.warehouses.list',
'lti.master.production_standards.list',
'lti.system_settings.update',
],
submenu: [
{
@@ -303,6 +308,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/master-data/production-standard',
permission: ['lti.master.production_standards.list'],
},
{
text: 'Konfigurasi Sistem',
link: '/master-data/system-config',
permission: ['lti.system_settings.update'],
},
],
},
] as const;
+2
View File
@@ -218,4 +218,6 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/master-data/production-standard/detail/edit/': [
'lti.master.production_standards.update',
],
'/master-data/system-config/': ['lti.system_settings.update'],
};

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