Compare commits

..

380 Commits

Author SHA1 Message Date
ValdiANS 192a2be8b9 fix: use project flock kandang ID instead kandang ID and use kandang name with period for kandang options in filter 2026-04-16 10:17:07 +07:00
Rivaldi A N S 11353809f0 Merge branch 'feat/expense-enhancement' into 'development'
[FEAT/FE] Expense Enhancement

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-web-client!344
2026-03-09 06:38:03 +07:00
Adnan Zahir 3042b54577 Merge branch 'fix/product-select-include-all-param' into 'development'
fix: add include all param to adjustment stock products select

See merge request mbugroup/lti-web-client!343
2026-03-09 00:51:43 +07:00
Adnan Zahir e5a686e5ee Merge branch 'hotfix/adjustment-fifo-stock-ttl' into 'development'
[HOTFIX/FE] FIFO Stock Adjustment for TTL, Recording and Project Flock (Chickin)

See merge request mbugroup/lti-web-client!342
2026-03-09 00:50:11 +07:00
Adnan Zahir 37d5a6b675 fix: add include all param to adjustment stock products select 2026-03-09 00:49:39 +07:00
rstubryan 2ff32094ce chore(FE): Fix inconsistent indentation in ChickLogsView and
RecordingForm
2026-03-08 22:13:26 +07:00
rstubryan 7207f1ba75 refactor(FE): Add isRecordingEditable check to detail actions 2026-03-08 22:12:29 +07:00
rstubryan 41d2e8737b feat(FE): Add chickin delete functionality with modal confirmation 2026-03-08 21:54:55 +07:00
rstubryan b2016314f5 refactor(FE): Restrict edit and delete actions based on recording status 2026-03-08 16:40:36 +07:00
rstubryan 7366d6490c refactor(FE): Validate transferToLayingId before fetching data 2026-03-08 16:34:59 +07:00
rstubryan e5e9b517fd refactor(FE): Update button visibility logic in TransferToLayingsTable 2026-03-08 16:17:20 +07:00
rstubryan b6629b0bbb refactor(FE): Set maxSourceQuantity in edit mode using existing data 2026-03-08 16:12:08 +07:00
rstubryan bac6766fa2 refactor(FE): Refactor transfer logic to use maxSourceQuantity state 2026-03-08 16:06:13 +07:00
Rivaldi A N S 9389fa0354 Merge branch 'fix/cleanup-warning-and-adjust-form-field' into 'development'
[FIX/FE] Cleanup Warning and Refactoring All Form Field (No Warning on Linter)

See merge request mbugroup/lti-web-client!341
2026-03-06 04:47:39 +00:00
rstubryan 37bc7a85e5 refactor(FE): Add depletion product handling in inventory adjustment
forms
2026-03-06 10:52:59 +07:00
rstubryan 667eb41eb2 refactor(FE): Refactor toggle logic for clarity in useModal 2026-03-06 10:08:13 +07:00
rstubryan e64b55e527 refactor(FE): Fix conditional call to onBackdropClick in Drawer
component
2026-03-06 10:00:25 +07:00
rstubryan 9710998dc6 refactor(FE): Handle null or undefined values in FinanceDetail component 2026-03-06 09:57:30 +07:00
rstubryan ca0b216ba0 refactor(FE): Handle null transaction_type and fix useEffect dependency 2026-03-06 09:53:57 +07:00
rstubryan 9ff6f3a35d refactor(FE): Refactor form components to improve state handling 2026-03-05 15:59:32 +07:00
rstubryan 333dd01f92 refactor(FE): Fix dependency array in useMemo for SalesClosingTable 2026-03-05 15:33:50 +07:00
rstubryan 75f765ee69 chore(FE): Add setTableState to useEffect dependency array 2026-03-05 15:05:17 +07:00
rstubryan 945bdb8b27 refactor(FE): Refactor formik field value setter in WarehousesTable 2026-03-05 15:02:00 +07:00
rstubryan 77eae32a3d refactor(FE): Refactor Formik field updates to use setFieldValue 2026-03-05 15:00:20 +07:00
rstubryan a5ebc6d1ae refactor(FE): Fix dependency arrays and improve error handling in
purchase components
2026-03-05 14:17:42 +07:00
rstubryan 15c7452d7b chore(FE): Fix missing dependency in useEffect hook 2026-03-05 14:15:41 +07:00
rstubryan 5dac900a1a refactor(FE): Refactor date filter state management in UniformityTable 2026-03-05 14:11:58 +07:00
rstubryan 4b49cd18f5 refactor(FE): Refactor form handlers to use consistent formik utilities 2026-03-05 13:45:01 +07:00
rstubryan a4cb4e202b refactor(FE): Fix dependency arrays and refactor setFieldValue usage 2026-03-05 13:36:43 +07:00
rstubryan 4a69eef294 fix(FE): Add handler to open filter modal and trigger form validation 2026-03-05 12:03:54 +07:00
rstubryan 2de6636bbf refactor(FE): Refactor tab actions to use memoized components 2026-03-05 11:59:27 +07:00
rstubryan a7951b6c28 refactor(FE): Fix missing dependencies in UniformityTable effect hooks 2026-03-05 11:47:16 +07:00
rstubryan 03baba40a6 refactor(FE): Remove unused params argument from
generateReportExpensePDF
2026-03-05 11:41:43 +07:00
rstubryan 96ef6f8496 refactor(FE): Refactor tab actions into a memoized component 2026-03-05 11:40:32 +07:00
rstubryan 94ab48d3f6 refactor(FE): Refactor tab actions to use memoized component 2026-03-05 11:21:56 +07:00
rstubryan ea88c3ce8e refactor(FE): Refactor tab actions to use memoized component 2026-03-05 11:10:36 +07:00
rstubryan 267ef9d812 refactor(FE): Refactor CustomerPaymentTab to use filterParams instead of
formik values
2026-03-05 11:01:52 +07:00
rstubryan b5fc1d4310 refactor(FE): Refactor tab actions to use memoized component 2026-03-05 10:57:13 +07:00
rstubryan c98e7d8cb3 refactor(FE): Fix population check to ensure proper boolean evaluation 2026-03-05 10:24:39 +07:00
rstubryan 1acbc91cfe refactor(FE): Reset dependent fields when location or project flock
changes
2026-03-05 10:19:30 +07:00
rstubryan 807041834b refactor(FE): Refactor formik field updates to use destructured methods 2026-03-05 09:54:50 +07:00
rstubryan 4de21561b3 refactor(FE): Refactor delivery order value initialization logic 2026-03-05 09:41:25 +07:00
rstubryan 1938f6cbda refactor(FE): Refactor DeliveryOrderProductTable to support delivery
order rendering
2026-03-04 17:13:30 +07:00
rstubryan f4a522fc0c refactor(FE): Refactor form field updates to use destructured Formik
methods
2026-03-04 16:48:21 +07:00
rstubryan 7ae04b3f3e refactor(FE): Refactor DashboardProduction callbacks for memoization 2026-03-04 14:12:45 +07:00
rstubryan b786acf71a chore(FE): Add setTableState to useEffect dependency arrays 2026-03-04 14:11:17 +07:00
rstubryan bb28ae8613 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-03-04 11:59:06 +07:00
rstubryan 080592ff01 chore(FE): Remove unused variables and imports across components 2026-03-04 11:58:35 +07:00
Rivaldi A N S 6d41203a5e Merge branch 'feat/global-search-store' into 'development'
[FEAT/FE] Add Global Search Store and Filter for Each Table Index (All Module)

See merge request mbugroup/lti-web-client!340
2026-03-04 04:44:27 +00:00
rstubryan 89d7d3ef91 refactor(FE): Format imports in PurchaseTable.tsx 2026-03-04 11:07:30 +07:00
rstubryan 18ebf75aa7 feat(FE): Integrate UI store and pathname handling in table components 2026-03-04 11:06:53 +07:00
rstubryan 6e7dfebacc feat(FE): Integrate UI store for table state and search handling 2026-03-04 10:56:35 +07:00
rstubryan 4b93cb4589 feat(FE): Integrate UI store state with InventoryProductTable 2026-03-04 10:51:51 +07:00
rstubryan 58f1ab82c7 refactor(FE): Refactor long lines for readability in MovementTable 2026-03-04 10:45:15 +07:00
rstubryan 1fb9687142 feat(FE): Add filter functionality to inventory tables 2026-03-04 10:44:22 +07:00
rstubryan 4f6d71f1f4 refactor(FE): Refactor payload creation in
PurchaseOrderAcceptApprovalForm
2026-03-04 10:20:50 +07:00
rstubryan d502ec707c refactor(FE): Move error message above form actions in
InventoryAdjustmentForm
2026-03-04 09:36:37 +07:00
rstubryan fa86f488e1 refactor(FE): Standardize search input placeholders to "Search" 2026-03-03 14:19:52 +07:00
rstubryan 5e5400f56b feat(FE): Add advanced filtering capabilities to master data tables 2026-03-03 14:03:29 +07:00
rstubryan 741884ac29 feat(FE): Add filter schemas and types for master data components 2026-03-03 14:02:06 +07:00
rstubryan 4e278c5687 refactor(FE): Integrate UI store and pathname sync in master-data tables 2026-03-03 10:59:26 +07:00
rstubryan 3d2e40518b refactor(FE): Add table state management with path grouping logic 2026-03-03 10:33:03 +07:00
Rivaldi A N S cbab7f52f2 Merge branch 'dev/hotfix/restu' into 'development'
[HOTFIX/FE] Fix Constant Value of Adjustment Stock (Recording Depletion In)

See merge request mbugroup/lti-web-client!339
2026-03-03 02:50:52 +00:00
rstubryan ceb316d3da refactor(FE): Refactor subtype label mapping and fix depletion subtype
value
2026-03-03 09:15:57 +07:00
Rivaldi A N S 9ea152aef9 Merge branch 'hotfix/adjustment-stock-api-usage' into 'development'
[HOTFIX/FE] Fix Inventory AdjustmentForm using Product API Instead of Product Warehouse API

See merge request mbugroup/lti-web-client!338
2026-03-02 08:58:16 +00:00
rstubryan 910ea85b62 refactor(FE): Refactor InventoryAdjustmentForm to simplify product
handling
2026-03-02 15:24:14 +07:00
rstubryan 968243c370 refactor(FE): Refactor tab actions store to use slice pattern 2026-03-02 14:44:20 +07:00
Rivaldi A N S f1ba577a97 Merge branch 'fix/master-data-product-nonstock' into 'development'
[FIX/FE] Adjustment Product and Nonstock Match with FIFO Stock V2

See merge request mbugroup/lti-web-client!337
2026-03-02 07:33:43 +00:00
rstubryan c6b906a28e refactor(FE): Fix loading state for flag and sub-flag dropdowns 2026-03-02 14:12:42 +07:00
rstubryan eafcfd2f28 refactor(FE): Fix null check for product_flag_mapping in ProductForm 2026-03-02 14:06:12 +07:00
rstubryan 0e5d38f75c refactor(FE): Update field name from 'isExpedition' to 'flags' in
handler
2026-03-02 13:57:56 +07:00
rstubryan f2b59ded3c feat(FE): Add skeleton components for master data tables 2026-03-02 13:50:25 +07:00
rstubryan 1341b1ff53 refactor(FE): Add skeleton loaders for inventory tables 2026-03-02 13:31:23 +07:00
rstubryan 9c4c750664 refactor(FE): Refactor table skeleton components for consistency 2026-03-02 12:10:06 +07:00
rstubryan d3501e5f3d refactor(FE): Add skeleton loaders for ProjectFlock and TransferToLaying
tables
2026-03-02 11:56:27 +07:00
rstubryan d96388e5f4 refactor(FE): Rename and update import for UniformityTableFilter module 2026-03-02 11:42:34 +07:00
rstubryan 749b7d6f1a refactor(FE): Refactor UniformityTable to improve loading and empty
states
2026-03-02 11:34:27 +07:00
rstubryan aadf10b8b9 refactor(FE): Refactor master-data pages to simplify component rendering 2026-03-02 11:17:54 +07:00
rstubryan 7db6ae4077 refactor(FE): Refactor NonstocksTable to use Popover for row actions 2026-03-02 10:47:17 +07:00
rstubryan 7eaf6b7a3a refactor(FE): Refactor ProductTable and RowOptionsMenu for improved
clarity
2026-03-02 10:42:34 +07:00
rstubryan 8397d76171 refactor(FE): Add fallback for product flag mapping on constants error 2026-03-02 10:15:53 +07:00
rstubryan 56d4b8a5c9 refactor(FE): Update ProductFlagMapping types and related references 2026-03-02 10:15:06 +07:00
rstubryan ef9d820c0d refactor(FE): Refactor ProductForm to fetch constants via SWR 2026-03-02 10:06:06 +07:00
rstubryan 4e3c6736ab refactor(FE): Rename "Sub Flags" header to "Kategori Flags" 2026-03-02 09:58:01 +07:00
rstubryan aa1ef7a559 feat(FE): Add constants transformation utilities and API service 2026-03-02 09:57:21 +07:00
rstubryan 04b5a7bd4d refactor(FE): Update label in NonstockForm to 'Flags Ekspedisi' 2026-02-27 15:42:24 +07:00
rstubryan 3aec412599 refactor(FE): Update label text in NonstockForm RadioGroup 2026-02-27 15:38:47 +07:00
rstubryan 80a94c48c3 refactor(FE): Format product prices using formatCurrency helper 2026-02-27 15:38:11 +07:00
rstubryan 9bd4a73a90 refactor(FE): Refactor flags field to boolean and update form handling 2026-02-27 15:36:44 +07:00
rstubryan d1c6fe8fb4 refactor(FE): Add conditional validation for sub_flags in
ProductForm.schema
2026-02-27 15:03:09 +07:00
rstubryan a4378ebd04 refactor(FE): Refactor product flags to support single flag and
sub-flags
2026-02-27 14:57:45 +07:00
rstubryan 16f2f2bc06 feat(FE): Add PRODUCT_FLAG_MAPPING with flags, sub-flags, and mappings 2026-02-27 14:08:17 +07:00
rstubryan 64843a36ab refactor(FE): Refactor date input components for consistency and layout 2026-02-27 13:34:51 +07:00
rstubryan 396a5ab5ba refactor(FE): Rename and update FinanceTableFilter schema import path 2026-02-27 13:12:12 +07:00
rstubryan 9f4c041ec8 refactor(FE): Update FinanceTable layout and conditional styling 2026-02-27 11:27:51 +07:00
rstubryan 5e3d2273d1 refactor(FE): Refactor RowOptionsMenu to use PopoverButton and
PopoverContent
2026-02-27 11:25:37 +07:00
rstubryan 0b01eefe20 refactor(FE): Update button labels for clarity in FinanceTable 2026-02-27 11:23:06 +07:00
rstubryan 43045b3f8b Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-02-27 11:21:34 +07:00
rstubryan d3ce60d3ba refactor(FE): Refactor FinanceTable and simplify Finance page structure 2026-02-27 11:14:13 +07:00
Rivaldi A N S 8c7577dcc5 Merge branch 'fix/adjustment-stock-and-ui-related' into 'development'
[FIX/FE] Refactor Adjustment Stock with Latest FIFO Stock and Adjustment UI (Production, Inventory, Pembelian, Penjualan, Biaya, Closing)

See merge request mbugroup/lti-web-client!336
2026-02-27 04:02:05 +00:00
rstubryan da3cadc37d Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/adjustment-stock-and-ui-related 2026-02-27 10:28:35 +07:00
rstubryan 2e6aa1b83a refactor(FE): Filter out excluded product flags in product options 2026-02-27 10:04:54 +07:00
rstubryan 8811976f53 chore(FE): Remove redundant comments in InventoryAdjustmentForm 2026-02-27 09:30:10 +07:00
rstubryan 9fb65bdacd feat(FE): Validate quantity against available stock 2026-02-27 09:29:33 +07:00
Adnan Zahir 53e018aece Merge branch 'staging' into 'production'
refactor(FE): Add tab state management and skeleton for

See merge request mbugroup/lti-web-client!335
2026-02-26 16:37:04 +07:00
rstubryan 6f6f54571f refactor(FE): Reset transaction subtype when product changes for
RECORDING
2026-02-26 16:20:00 +07:00
rstubryan 3099588141 refactor(FE): Filter transaction subtypes based on selected product
flags
2026-02-26 16:10:19 +07:00
rstubryan 47ee911852 feat(FE): Add stock availability badge to product dropdown 2026-02-26 15:56:31 +07:00
rstubryan b4d0ed1537 refactor(FE): Refactor product options handling in
InventoryAdjustmentForm
2026-02-26 15:39:49 +07:00
rstubryan 36f2368e95 refactor(FE): Refactor product selection to use ProductWarehouse API 2026-02-26 15:31:43 +07:00
rstubryan a4d5cbb117 refactor(FE): Simplify conditional formatting in InventoryAdjustmentForm 2026-02-26 15:12:19 +07:00
rstubryan c926a81756 refactor(FE): Update transaction subtype handling and labels 2026-02-26 15:11:39 +07:00
rstubryan 2476b6a4b4 refactor(FE): Format prices and totals as currency in
InventoryAdjustmentTable
2026-02-26 14:52:44 +07:00
rstubryan 01c1843fd5 refactor(FE): Refactor MovementTable to use Popover for row actions 2026-02-26 14:37:34 +07:00
rstubryan f86498e350 refactor(FE): Refactor InventoryProductTable to use Popover for row
options
2026-02-26 14:29:27 +07:00
rstubryan 5cd24f2c46 refactor(FE): Fix form error list visibility and field touch handling 2026-02-26 14:12:11 +07:00
rstubryan da0be9cb52 refactor(FE): Update input components to adjust spacing and text size 2026-02-26 14:11:33 +07:00
rstubryan da1cdfb59e refactor(FE): Refactor transaction type handling with useEffect 2026-02-26 13:58:21 +07:00
rstubryan a991150262 refactor(FE): Refactor InventoryAdjustmentForm layout for better
responsiveness
2026-02-26 13:32:52 +07:00
rstubryan ee49c91fba refactor(FE): Add type annotation for inventory adjustment payload 2026-02-26 12:08:36 +07:00
rstubryan 75463326e3 refactor(FE): Refactor InventoryAdjustmentForm to remove 'edit' type
handling
2026-02-26 12:07:14 +07:00
rstubryan e98c49ac5a refactor(FE): Update columns in InventoryAdjustmentTable 2026-02-26 11:48:27 +07:00
rstubryan aeeb0b721c refactor(FE): Refactor InventoryAdjustmentTable for improved readability 2026-02-26 11:31:17 +07:00
Rivaldi A N S b14bc00af4 Merge branch 'fix/fe/return-to-logout' into 'development'
[FIX/FE] add return fe cors

See merge request mbugroup/lti-web-client!334
2026-02-26 04:27:56 +00:00
ragilap e2a4088e77 [FEAT/BE] add return fe cors 2026-02-26 11:19:47 +07:00
rstubryan ebe80358ee refactor(FE): Remove unused import isResponseSuccess from
InventoryAdjustmentForm
2026-02-26 11:11:51 +07:00
rstubryan 9d4e9f6318 refactor(FE): Refactor InventoryAdjustmentForm to use useSelect for
Kandang
2026-02-26 11:09:13 +07:00
rstubryan e5007a285a refactor(FE): Refactor InventoryAdjustmentForm to improve state
management
2026-02-26 10:38:41 +07:00
rstubryan da82c704d5 refactor(FE): Fix validation message formatting in
InventoryAdjustmentForm
2026-02-26 10:12:48 +07:00
rstubryan 88b9c890e5 refactor(FE): Refactor inventory adjustment form 2026-02-26 10:10:59 +07:00
rstubryan 5a67901722 feat(FE): Add transaction type and subtype options to constants 2026-02-26 09:11:05 +07:00
rstubryan 0031a65f97 refactor(FE): Ensure filter modal closes on reset across components 2026-02-25 16:30:31 +07:00
rstubryan a89e83af29 refactor(FE): Fix multiline formatting for isError conditions 2026-02-25 15:57:25 +07:00
rstubryan a75d84556a refactor(FE): Refactor date validation to use shared state and cleanup 2026-02-25 15:56:12 +07:00
rstubryan 0af7b172a0 refactor(FE): Refactor PurchaseTable to use Popover for row options menu 2026-02-25 12:08:31 +07:00
rstubryan 8be33b230b refactor(FE): Refactor row options menu to use popover components 2026-02-25 11:59:17 +07:00
rstubryan 4fda2f661a refactor(FE): Remove unnecessary padding and margin classes in
components
2026-02-25 11:49:41 +07:00
rstubryan 22b1102454 refactor(FE): Refactor ExpensesTable to use ExpensesFilterModal 2026-02-25 11:40:42 +07:00
rstubryan e2e64f093f refactor(FE): Refactor TransferToLayingFilterModal to use schema
validation
2026-02-25 11:02:26 +07:00
rstubryan 90942b41b9 refactor(FE): Fix formatting of disabled condition in RecordingTable
button
2026-02-25 10:53:32 +07:00
rstubryan 93a2d99b7f refactor(FE): Disable filter button if kandang_id is not selected 2026-02-25 10:52:03 +07:00
rstubryan dae9a24a7c refactor(FE): Refactor array formatting and improve code readability 2026-02-25 10:41:34 +07:00
rstubryan f701ab0d91 refactor(FE): Replace active filter count logic with ButtonFilter
component
2026-02-25 10:39:14 +07:00
rstubryan 47a2439777 feat(FE): Add support for grouped fields in ButtonFilter active count
calculation
2026-02-25 10:07:26 +07:00
rstubryan cce84a3a6f refactor(FE): Refactor ButtonFilter to support excluded fields with
useMemo
2026-02-25 09:46:19 +07:00
Rivaldi A N S f92622cc22 Merge branch 'fix/adjustment-production-module' into 'development'
[FIX/FE] Adjustment Production Module (Recording & Uniformity)

See merge request mbugroup/lti-web-client!333
2026-02-25 02:32:21 +00:00
rstubryan f0041ca938 refactor(FE): Filter inactive kandangs in UniformityForm options 2026-02-24 21:05:22 +07:00
rstubryan 6566b881b2 refactor(FE): Use formatTitleCase for category text in StatusBadge 2026-02-24 21:02:56 +07:00
rstubryan 1a2e38568b refactor(FE): Remove unused page size selector and related logic 2026-02-24 15:46:32 +07:00
rstubryan ced3970aae refactor(FE): Add padding to RecordingTableSkeleton container class 2026-02-24 15:38:19 +07:00
rstubryan 9de31c991d refactor(FE): Refactor approval checks for readability 2026-02-24 15:34:11 +07:00
rstubryan bdca10e0ac refactor(FE): Handle rejected recordings in selection logic 2026-02-24 15:31:22 +07:00
rstubryan 9a5e2987d5 refactor(FE): Conditionally load data based on filter modal state 2026-02-24 15:22:06 +07:00
rstubryan a5b4deaac4 refactor(FE): Refactor RecordingTable layout for improved readability 2026-02-24 15:13:37 +07:00
rstubryan 82953f4c9c Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-02-24 14:48:34 +07:00
rstubryan 509fc5476d refactor(FE): Refactor recording table and add filter modal 2026-02-24 14:48:09 +07:00
Rivaldi A N S 6a10849a84 Merge branch 'fix/adjustment-pf-report-penjualan' into 'development'
[FIX/FE] Adjustment Project Flock Related, Penjualan Calculation (Both SO/DO) and Report Filter Display Issue

See merge request mbugroup/lti-web-client!332
2026-02-24 03:24:42 +00:00
rstubryan 50424a25fc refactor(FE): Remove redundant transformations and validations in
schemas
2026-02-23 16:05:12 +07:00
rstubryan 35b09b514f refactor(FE): Refactor form field change handlers for real-time
calculations
2026-02-23 15:30:54 +07:00
rstubryan 81e4e1fc6a refactor(FE): Update validation rules for SalesOrderProduct schema 2026-02-23 14:12:44 +07:00
rstubryan 62c9ab014d refactor(FE): Add period filter to ProjectFlockTable 2026-02-23 13:51:32 +07:00
rstubryan ba28d64562 refactor(FE): Format type definition for readability 2026-02-23 13:40:00 +07:00
rstubryan 75ee058818 feat(FE): Add filter functionality to ProjectFlockTable 2026-02-23 13:38:47 +07:00
rstubryan 755bddc74c refactor(FE): Fix logic for checking active projects in the same kandang
from the validation logic.
2026-02-23 12:10:44 +07:00
rstubryan 08aa1900a8 feat(FE): Add validation for disabling close button in
ProjectFlockDetail
2026-02-23 11:55:07 +07:00
rstubryan 7e1166b5e8 refactor(FE): Refactor tab actions store to use a unified implementation 2026-02-23 11:05:11 +07:00
rstubryan 75e9b06a83 refactor(FE): Set default value for show_unrecorded to false 2026-02-23 10:27:34 +07:00
Adnan Zahir b2f6c6c485 Merge branch 'staging' into 'production'
Staging

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

See merge request mbugroup/lti-web-client!295
2026-01-31 10:39:33 +07:00
Adnan Zahir 92c07e7841 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-web-client!292
2026-01-30 16:13:35 +07:00
Adnan Zahir 847772616e Merge branch 'staging' into 'production'
Staging

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

See merge request mbugroup/lti-web-client!261
2026-01-27 10:36:16 +07:00
Adnan Zahir 32f4cf411f Merge branch 'staging' into 'production'
Staging

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

See merge request mbugroup/lti-web-client!249
2026-01-24 13:06:33 +07:00
Adnan Zahir 5c50e4a0c1 Merge branch 'staging' into 'production'
Staging

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

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

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

See merge request mbugroup/lti-web-client!214
2026-01-20 11:52:20 +07:00
kris 8fd442621a Update .gitlab-ci.yml file 2026-01-14 02:52:03 +00:00
kris 35471fc597 Update .gitlab-ci.yml file 2026-01-14 02:29:31 +00:00
221 changed files with 16927 additions and 8484 deletions
+3
View File
@@ -45,3 +45,6 @@ next-env.d.ts
# claude
.claude
# rtk
rtk.exe
+30 -2
View File
@@ -15,7 +15,7 @@ default:
# ==========================================================
.build_template: &build_template
stage: build
image: node:20-alpine
image: public.ecr.aws/docker/library/node:20-alpine
cache:
key: npm-cache
paths:
@@ -56,7 +56,7 @@ default:
.deploy_template: &deploy_template
stage: deploy
image:
name: amazon/aws-cli:latest
name: public.ecr.aws/aws-cli/aws-cli:latest
entrypoint: ['/bin/sh', '-c']
script:
- set -e
@@ -183,3 +183,31 @@ deploy:staging:
environment:
name: staging
url: https://stg-lti-erp.mbugroup.id
# ==========================================================
# ====== STAGING (Branch production) ======
# ==========================================================
build:production:
<<: *build_template
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
environment:
name: staging
variables:
NEXT_PUBLIC_LTI_URL: 'https://lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
deploy:production:
<<: *deploy_template
needs: ['build:production']
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
variables:
S3_BUCKET: 'production-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E1SSLXKYYITASJ'
environment:
name: staging
url: https://lti-erp.mbugroup.id
+1 -1
View File
@@ -1,3 +1,3 @@
npm run format
npm run lint
npx tsc --noEmit
npm run typecheck
+15 -28
View File
@@ -1,38 +1,25 @@
# =========================
# Builder stage
# =========================
FROM golang:1.23-alpine AS builder
FROM public.ecr.aws/docker/library/node:20-alpine
RUN apk add --no-cache git bash build-base curl
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY package*.json ./
RUN npm ci
COPY . .
# Build API binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api
# Buat config agar Next tahu output: export
RUN echo "const config = { output: 'export', images: { unoptimized: true } }; export default config;" > next.config.mjs
# Build SEED binary (pastikan cmd/seed ada)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed
# Build project (Next.js 15 otomatis static export)
RUN NEXT_DISABLE_TURBOPACK=1 npx next build
# =========================
# Runtime stage
# =========================
FROM alpine:3.20
# Copy static assets dan hasil build agar bisa diakses
RUN mkdir -p .next/server/app/_next && \
cp -r .next/static .next/server/app/_next/static && \
cp -r public/* .next/server/app/
RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
&& adduser -D -H -u 10001 appuser
EXPOSE 3000
WORKDIR /app
COPY --from=builder /app/lti-api /app/lti-api
COPY --from=builder /app/lti-seed /app/lti-seed
USER appuser
EXPOSE 8081
CMD ["/app/lti-api"]
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
+39
View File
@@ -0,0 +1,39 @@
version: '3.9'
services:
dev-web-lti:
container_name: dev-web-lti
build:
context: .
dockerfile: Dockerfile
ports:
- '3002:3000'
env_file:
- .env
environment:
NODE_ENV: production
APP_ENV: production
networks:
- dev-lti-network
restart: always
deploy:
resources:
limits:
cpus: '3.0'
memory: 3G
reservations:
cpus: '1.0'
memory: 512M
extra_hosts:
- 'host.docker.internal:host-gateway'
# Optional: aktifkan healthcheck jika punya endpoint
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/healthz || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 15s
networks:
dev-lti-network:
external: true
+3 -1
View File
@@ -7,8 +7,10 @@
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"typecheck": "next typegen && tsc --noEmit",
"prepare": "husky",
"format": "prettier --write ."
"format": "prettier --write .",
"pre-commit": "npm run format && npm run lint && npm run typecheck && npm run build"
},
"dependencies": {
"@react-pdf/renderer": "^4.3.1",
@@ -0,0 +1,11 @@
import { MasterKandangContent } from '@/figma-make/components/pages/master-data/kandang/MasterKandangContent';
const MasterKandangPage = () => {
return (
<section className='w-full'>
<MasterKandangContent />
</section>
);
};
export default MasterKandangPage;
+1 -6
View File
@@ -3,12 +3,7 @@
import FinanceTable from '@/components/pages/finance/FinanceTable';
const Finance = () => {
return (
<section className='size-full p-6'>
<div className='flex flex-row gap-4'></div>
<FinanceTable />
</section>
);
return <FinanceTable />;
};
export default Finance;
+1 -1
View File
@@ -2,7 +2,7 @@ import InventoryAdjustmentTable from '@/components/pages/inventory/adjustment/In
const InventoryAdjustment = () => {
return (
<section className='w-full p-4'>
<section className='w-full'>
<InventoryAdjustmentTable />
</section>
);
+1 -5
View File
@@ -1,11 +1,7 @@
import AreasTable from '@/components/pages/master-data/area/AreasTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<AreasTable />
</section>
);
return <AreasTable />;
};
export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import BanksTable from '@/components/pages/master-data/bank/BanksTable';
const Bank = () => {
return (
<section className='w-full p-4'>
<BanksTable />
</section>
);
return <BanksTable />;
};
export default Bank;
+1 -5
View File
@@ -1,11 +1,7 @@
import CustomersTable from '@/components/pages/master-data/customer/CustomersTable';
const Customer = () => {
return (
<section className='w-full p-4'>
<CustomersTable />
</section>
);
return <CustomersTable />;
};
export default Customer;
+1 -5
View File
@@ -1,11 +1,7 @@
import FlockTable from '@/components/pages/master-data/flock/FlocksTable';
const Flock = () => {
return (
<section className='w-full p-4'>
<FlockTable />
</section>
);
return <FlockTable />;
};
export default Flock;
+1 -5
View File
@@ -1,11 +1,7 @@
import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<KandangsTable />
</section>
);
return <KandangsTable />;
};
export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import LocationsTable from '@/components/pages/master-data/location/LocationsTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<LocationsTable />
</section>
);
return <LocationsTable />;
};
export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<NonstocksTable />
</section>
);
return <NonstocksTable />;
};
export default Nonstock;
@@ -1,11 +1,7 @@
import ProductCategoryTable from '@/components/pages/master-data/product-category/ProductCategoryTable';
const ProductCategory = () => {
return (
<section className='w-full p-4'>
<ProductCategoryTable />
</section>
);
return <ProductCategoryTable />;
};
export default ProductCategory;
+1 -5
View File
@@ -1,11 +1,7 @@
import ProductsTable from '@/components/pages/master-data/product/ProductTable';
const Product = () => {
return (
<section className='w-full p-4'>
<ProductsTable />
</section>
);
return <ProductsTable />;
};
export default Product;
@@ -1,11 +1,7 @@
import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable';
const ProductionStandardPage = () => {
return (
<div className='w-full'>
<ProductionStandardTable />
</div>
);
return <ProductionStandardTable />;
};
export default ProductionStandardPage;
+1 -5
View File
@@ -1,11 +1,7 @@
import SuppliersTable from '@/components/pages/master-data/supplier/SupplierTable';
const Supplier = () => {
return (
<section className='w-full p-4'>
<SuppliersTable />
</section>
);
return <SuppliersTable />;
};
export default Supplier;
+1 -5
View File
@@ -1,11 +1,7 @@
import UomsTable from '@/components/pages/master-data/uom/UomsTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<UomsTable />
</section>
);
return <UomsTable />;
};
export default Nonstock;
+1 -5
View File
@@ -1,11 +1,7 @@
import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable';
const Warehouse = () => {
return (
<section className='w-full p-4'>
<WarehousesTable />
</section>
);
return <WarehousesTable />;
};
export default Warehouse;
@@ -11,10 +11,13 @@ const RecordingEdit = () => {
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId,
(id: string) => RecordingApi.getSingle(parseInt(id))
recordingDetailKey,
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
);
if (!recordingId) {
+5 -2
View File
@@ -11,10 +11,13 @@ const RecordingDetail = () => {
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId,
(id: string) => RecordingApi.getSingle(parseInt(id))
recordingDetailKey,
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
);
if (!recordingId) {
+1 -1
View File
@@ -2,7 +2,7 @@ import RecordingTable from '@/components/pages/production/recording/RecordingTab
const Recording = () => {
return (
<section className='w-full p-4 sm:p-0'>
<section className='w-full'>
<RecordingTable />
</section>
);
+3 -3
View File
@@ -51,7 +51,7 @@ const Button = ({
return (
<>
{!href && (
{(!href || (href && disabled)) && (
<button
{...props}
type={type}
@@ -68,9 +68,9 @@ const Button = ({
</button>
)}
{href && (
{href && !disabled && (
<Link
href={disabled ? '#' : href}
href={href}
target={target}
rel={rel}
aria-disabled={disabled}
+3 -1
View File
@@ -108,7 +108,9 @@ const Drawer = ({
if (closeOnBackdropClick) {
setOpen(false);
}
onBackdropClick && onBackdropClick();
if (onBackdropClick) {
onBackdropClick();
}
};
return (
+5 -1
View File
@@ -31,7 +31,11 @@ export const useModal = (isNestingModal = false) => {
}, []);
const toggle = useCallback(() => {
open ? closeModal() : openModal();
if (open) {
closeModal();
} else {
openModal();
}
}, [open, closeModal, openModal]);
useEffect(() => {
+5 -1
View File
@@ -26,13 +26,17 @@ const Navbar = ({ toggleSidebar }: NavbarProps) => {
const logoutClickHandler = async () => {
const logoutRes = await AuthApi.logout();
if (isResponseError(logoutRes)) {
toast.error('Gagal logout! Coba lagi!');
return;
}
setUser(undefined);
const redirect = (logoutRes as { redirect?: string })?.redirect;
if (redirect) {
window.location.href = redirect;
return;
}
router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
};
+41 -7
View File
@@ -3,15 +3,51 @@ import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
import { FormikValues } from 'formik';
import { useMemo } from 'react';
export type ButtonFilterProps = ButtonProps & {
values: FormikValues;
onClick: () => void;
excludeFields?: string[];
fieldGroups?: string[][];
};
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
const ButtonFilter = ({
values,
onClick,
excludeFields = [],
fieldGroups = [],
...props
}: ButtonFilterProps) => {
const activeCount = useMemo(() => {
const filteredValues: FormikValues = {};
Object.keys(values).forEach((key) => {
if (!excludeFields.includes(key)) {
filteredValues[key] = values[key];
}
});
let count = getFilledFormikValuesCount(filteredValues);
fieldGroups.forEach((group) => {
const groupFields = group.filter(
(field) => !excludeFields.includes(field)
);
const filledGroupFields = groupFields.filter(
(field) => filteredValues[field]
);
if (
filledGroupFields.length === groupFields.length &&
groupFields.length > 1
) {
count -= groupFields.length - 1;
}
});
return count;
}, [values, excludeFields, fieldGroups]);
return (
<Button
{...props}
@@ -21,7 +57,7 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
className={cn(
'rounded-lg max-h-10 font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
getFilledFormikValuesCount(values) > 0
activeCount > 0
? 'border-primary-gradient text-primary rounded-lg!'
: 'rounded-lg',
props.className
@@ -31,14 +67,12 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
icon='heroicons:funnel'
width={20}
height={20}
className={
getFilledFormikValuesCount(values) > 0 ? 'text-blue-600' : ''
}
className={activeCount > 0 ? 'text-blue-600' : ''}
/>
Filter
{getFilledFormikValuesCount(values) > 0 && (
{activeCount > 0 && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{getFilledFormikValuesCount(values)}
{activeCount}
</span>
)}
</Button>
+8 -2
View File
@@ -134,14 +134,20 @@ const DropFileInput: React.FC<DropFileInputProps> = ({
{!isError && bottomLabel && (
<p
className={cn('w-full text-sm opacity-60', className?.bottomLabel)}
className={cn(
'w-full mt-1.5 text-xs opacity-60',
className?.bottomLabel
)}
>
{bottomLabel}
</p>
)}
{isError && (
<p
className={cn('w-full text-sm text-error', className?.errorMessage)}
className={cn(
'w-full mt-1.5 text-xs text-error',
className?.errorMessage
)}
>
{errorMessage}
</p>
+3 -1
View File
@@ -35,7 +35,9 @@ const NumberInput = ({
| undefined;
if (newChangeEvent) {
newChangeEvent.target.value = numberFormatValues.value;
newChangeEvent.target.value = parseFloat(
numberFormatValues.value
) as unknown as string;
onChange?.(newChangeEvent);
}
+2 -2
View File
@@ -144,12 +144,12 @@ export const RadioGroup = ({
{/* Label bawah */}
{!isError && bottomLabel && (
<p className='text-sm opacity-60'>{bottomLabel}</p>
<p className='mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
{/* Pesan error */}
{isError && errorMessage && (
<p className='text-sm text-error'>{errorMessage}</p>
<p className='mt-1.5 text-xs text-error'>{errorMessage}</p>
)}
</div>
</RadioGroupContext.Provider>
+28 -18
View File
@@ -24,8 +24,8 @@ import {
} from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType {
value: string | number;
export interface OptionType<T = string | number> {
value: T;
label: string;
className?: string;
labelClassName?: string;
@@ -488,9 +488,11 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
/>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{isError && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
</div>
);
@@ -564,23 +566,31 @@ const useSelect = <T,>(
setSize(size + 1);
};
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
if (isResponseSuccess(pages?.[latestPagesIndex])) {
formattedSuccessRawData = {
...pages?.[latestPagesIndex],
data:
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
[],
};
}
const { formattedSuccessRawData, formattedErrorRawData } = useMemo(() => {
let successData: SuccessApiResponse<T[]> | undefined = undefined;
let errorData: ErrorApiResponse | undefined = undefined;
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
if (isResponseSuccess(pages?.[latestPagesIndex])) {
successData = {
...pages![latestPagesIndex],
data:
pages?.flatMap((page) =>
isResponseSuccess(page) ? page.data : []
) ?? [],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
errorData = pages![latestPagesIndex];
}
return {
formattedSuccessRawData: successData,
formattedErrorRawData: errorData,
};
}, [pages, latestPagesIndex]);
return {
inputValue,
+4 -2
View File
@@ -159,9 +159,11 @@ const TagInput: React.FC<TagInputProps> = ({
{/* Bottom label or error message */}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
{isError && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
@@ -18,7 +18,7 @@ import HppExpeditionClosingTab from '@/components/pages/closing/tab/HppExpeditio
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { useClosingTabStore } from '@/stores/closing/closing-tab.store';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
interface ClosingDetailProps {
id: number;
initialValue?: ClosingGeneralInformation;
@@ -33,7 +33,7 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
kandangData,
}) => {
const [activeTabId, setActiveTabId] = useState<string>('sapronak');
const tabActions = useClosingTabStore((state) => state.tabActions);
const tabActions = useTabActionsStore((state) => state.tabActions);
const closingDetailTabs = useMemo(() => {
const validTabs = [
@@ -112,12 +112,11 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
kandangData={kandangData}
/>
{!kandangData && (
<ClosingKandangList
initialValue={initialValue}
projectData={projectData}
/>
)}
<ClosingKandangList
initialValue={initialValue}
projectData={projectData}
selectedKandangId={kandangData?.id}
/>
<Tabs
activeTabId={activeTabId}
@@ -5,9 +5,11 @@ import { ProjectFlock } from '@/types/api/production/project-flock';
const ClosingKandangList = ({
initialValue,
projectData,
selectedKandangId,
}: {
initialValue?: ClosingGeneralInformation;
projectData?: ProjectFlock;
selectedKandangId?: number;
}) => {
return (
<div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'>
@@ -22,6 +24,9 @@ const ClosingKandangList = ({
variant='outline'
className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm'
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
disabled={
selectedKandangId === kandang.project_flock_kandang_id
}
>
{kandang.name}
</Button>
+33 -50
View File
@@ -1,6 +1,8 @@
'use client';
import { ChangeEventHandler, useEffect, useState, useMemo } from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
@@ -31,6 +33,7 @@ import {
ClosingFilterType,
} from '@/components/pages/closing/filter/ClosingFilter';
import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter';
const RowOptionsMenu = ({
props,
@@ -90,6 +93,9 @@ const RowOptionsMenu = ({
};
const ClosingsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
// ===== ROUTER =====
const router = useRouter();
@@ -158,6 +164,7 @@ const ClosingsTable = () => {
onReset: () => {
updateFilter('location_id', '');
updateFilter('project_status', '');
filterModal.closeModal();
},
});
@@ -287,25 +294,17 @@ const ClosingsTable = () => {
);
}, [formik.values.project_status, projectStatusOptions]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
if (tableFilterState.location_id) {
count += 1;
}
if (tableFilterState.project_status) {
count += 1;
}
return count;
}, [tableFilterState.location_id, tableFilterState.project_status]);
const hasFilters = activeFiltersCount > 0;
useEffect(() => {
setTableState('closing-table', pathname);
}, [pathname, setTableState]);
// ===== SEARCH CHANGE HANDLER =====
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
@@ -352,25 +351,12 @@ const ClosingsTable = () => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
className='px-3 py-2.5'
/>
</div>
</div>
@@ -379,19 +365,19 @@ const ClosingsTable = () => {
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<ClosingTableSkeleton
columns={closingsColumns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Closing Belum Tersedia'
subtitle='Tidak ada data closing untuk saat ini.'
/>
<div className='mt-3'>
<ClosingTableSkeleton
columns={closingsColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Closing>
data={isResponseSuccess(closings) ? closings?.data : []}
@@ -410,10 +396,7 @@ const ClosingsTable = () => {
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn('mt-3', {
'w-full mb-0':
isResponseSuccess(closings) && closings?.data?.length === 0,
}),
containerClassName: cn('mt-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
@@ -6,13 +6,13 @@ import { ColumnDef } from '@tanstack/react-table';
const ClosingTableSkeleton = ({
columns,
icon,
title,
subtitle,
title = 'No Data Available',
subtitle = 'There is no closing data displayed. Enter closing data to get started.',
}: {
columns: ColumnDef<Closing>[];
icon: React.ReactNode;
title: string;
subtitle: string;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
@@ -276,7 +276,7 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
{
id: 'kandang',
accessorKey: 'kandang',
header: 'Kandang',
header: 'Kandang Atribusi',
cell: (props) => {
const kandang = props.getValue() as Kandang;
return kandang?.name || '-';
@@ -308,7 +308,16 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
// },
// },
],
[]
[
summary,
totals.avgActualPrice,
totals.avgSalesPrice,
totals.avgWeight,
totals.totalActualPrice,
totals.totalQuantity,
totals.totalSalesPrice,
totals.totalWeight,
]
);
return (
@@ -127,11 +127,11 @@ const ClosingOutgoingSapronaksTable = ({
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
header: 'Gudang Asal (Fisik)',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
header: 'Gudang Tujuan (Fisik)',
},
{
accessorKey: 'quantity',
@@ -9,8 +9,11 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik';
import { ProjectFlockApi } from '@/services/api/production';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import { LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import {
DashboardFilterType,
@@ -22,10 +25,7 @@ import DashboardExportCharts, {
DashboardExportChartsRef,
} from '@/components/pages/dashboard/export/DashboardExportCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import {
DashboardFilter,
DashboardMeta,
} from '@/types/api/dashboard/dashboard';
import { DashboardMeta } from '@/types/api/dashboard/dashboard';
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
@@ -42,6 +42,8 @@ import { cn } from '@/lib/helper';
import DashboardExportStats, {
DashboardExportStatsRef,
} from '@/components/pages/dashboard/export/DashboardExportStats';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
// Helper function to normalize values to array
const normalizeToArray = (
@@ -68,7 +70,6 @@ const DashboardProduction = () => {
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
);
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
normalizeToArray(filterValues.location)
);
@@ -80,9 +81,29 @@ const DashboardProduction = () => {
const {
data: dashboardProductionResponse,
isLoading: isLoadingDashboardProductionData,
mutate: refreshDashboardProductionData,
} = useSWR(endpointUrl, () =>
DashboardApi.getDashboardProductionFetcher(endpointUrl)
} = useSWR(
[
'dashboard-production',
filterValues.startDate ?? '',
filterValues.endDate ?? '',
filterValues.analysisMode ?? 'OVERVIEW',
normalizeToArray(filterValues.location).toString(),
normalizeToArray(filterValues.flock).toString(),
normalizeToArray(filterValues.kandang).toString(),
filterValues.comparisonType ?? '',
],
() =>
DashboardApi.getDashboardProductionFetcher({
start_date: filterValues.startDate || '',
end_date: filterValues.endDate || '',
analysis_mode:
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') ||
'OVERVIEW',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType || '',
})
);
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
@@ -95,23 +116,23 @@ const DashboardProduction = () => {
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
}
);
const {
setInputValue: setInputValueLocation,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' },
@@ -135,75 +156,56 @@ const DashboardProduction = () => {
enableReinitialize: true,
validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => {
// Save filter values to store
setFilterValues(values);
handleApplyFilter({
start_date: values.startDate || '',
end_date: values.endDate || '',
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(values.location),
flock_ids: normalizeToArray(values.flock),
kandang_ids: normalizeToArray(values.kandang),
comparison_type: values.comparisonType,
});
filterModal.closeModal();
},
});
const { resetForm } = formik;
const selectedLocationValues = normalizeToArray(formik.values.location);
const selectedFlockValues = normalizeToArray(formik.values.flock);
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'name_with_period',
'search',
{
location_id:
selectedLocationValues.length > 0
? selectedLocationValues.toString()
: '',
project_flock_id:
selectedFlockValues.length > 0 ? selectedFlockValues.toString() : '',
}
);
const handleResetFilter = useCallback(() => {
formik.resetForm();
resetForm();
resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards');
setSelectedLocationIds([]);
}, [resetFilterValues, filterValues, selectedLocationIds]);
const handleApplyFilter = (values: DashboardFilter) => {
// Build query params object, only include non-empty values
const params: Record<string, string> = {};
if (values.start_date) params.start_date = values.start_date;
if (values.end_date) params.end_date = values.end_date;
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
if (values.location_ids.length > 0)
params.location_ids = values.location_ids.toString();
if (values.flock_ids.length > 0)
params.flock_ids = values.flock_ids.toString();
if (values.kandang_ids.length > 0)
params.kandang_ids = values.kandang_ids.toString();
if (values.comparison_type) params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
filterModal.closeModal();
refreshDashboardProductionData();
};
// ===== Load filter from store on mount =====
useEffect(() => {
if (!filterValues) return;
handleApplyFilter({
start_date: filterValues.startDate,
end_date: filterValues.endDate,
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType,
});
}, [filterValues]);
}, [filterModal, resetForm, resetFilterValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// ===== Export PDF =====
const handleExportPDF = async () => {
const handleExportPDF = useCallback(async () => {
await generateDashboardPDF({
filterValues: formik.values,
allStatsRef,
allChartsRef,
setExporting,
});
};
}, [formik.values]);
// ===== Register Navbar Actions =====
const openFilterModalRef = useRef(filterModal.openModal);
@@ -253,7 +255,7 @@ const DashboardProduction = () => {
</Dropdown>
</div>
);
}, [formik.values, exporting, setNavbarActions]);
}, [formik.values, exporting, setNavbarActions, handleExportPDF]);
// Cleanup only on unmount
useEffect(() => {
@@ -262,14 +264,6 @@ const DashboardProduction = () => {
};
}, [clearNavbarActions]);
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
);
}
return (
<>
<section className='w-full p-3 space-y-3'>
@@ -321,9 +315,15 @@ const DashboardProduction = () => {
</div>
{/* Dashboard Stats */}
<div>
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
{isLoadingDashboardProductionData ? (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
) : (
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
)}
</div>
{/* Use DashboardLineChart component or skeleton */}
@@ -362,7 +362,7 @@ const DashboardProduction = () => {
</div>
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
{dashboardProductionData && (
{exporting && dashboardProductionData && (
<>
{/* Export Stats Charts */}
<div
@@ -531,6 +531,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -567,6 +568,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -598,6 +600,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -637,6 +640,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -663,6 +667,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -701,6 +706,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -727,6 +733,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -409,14 +409,14 @@ const DashboardLineChart = ({
axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
// let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
// seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
@@ -426,7 +426,7 @@ const DashboardLineChart = ({
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
// seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
@@ -9,6 +10,7 @@ import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestCont
import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent';
import { Expense } from '@/types/api/expense';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
interface ExpenseDetailProps {
initialValues?: Expense;
@@ -16,6 +18,8 @@ interface ExpenseDetailProps {
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const [activeTab, setActiveTab] = useState<string>('request');
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const expenseDetailTabs = useMemo(() => {
const validTabs = [
@@ -46,7 +50,7 @@ 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'
href={returnTo}
variant='link'
className='w-fit p-0 text-primary'
>
@@ -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,7 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -31,6 +31,10 @@ 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 +44,8 @@ const ExpenseRequestContent = ({
initialValues,
}: ExpenseRequestContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
useApprovalSteps({
@@ -148,7 +154,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 +170,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 +210,7 @@ const ExpenseRequestContent = ({
toast.success(approveResponse?.message);
setApprovalNotes('');
router.push('/expense');
router.push(returnTo);
} else {
approveModal.closeModal();
@@ -239,7 +245,7 @@ const ExpenseRequestContent = ({
toast.success(rejectResponse.message);
setApprovalNotes('');
router.push('/expense');
router.push(returnTo);
} else {
rejectModal.closeModal();
@@ -279,8 +285,6 @@ const ExpenseRequestContent = ({
)}
<div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */}
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'>
@@ -367,7 +371,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
@@ -386,7 +394,11 @@ const ExpenseRequestContent = ({
<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} />
+392 -334
View File
@@ -1,6 +1,14 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr';
import {
CellContext,
@@ -16,41 +24,53 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import DateInput from '@/components/input/DateInput';
import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTableSkeleton';
import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense';
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { BaseApiResponse } from '@/types/api/api-general';
type ExpenseTableFilters = {
search: string;
nameSort: string;
transactionDate: string;
realizationDate: string;
locationId: string;
vendorId: string;
userId: string;
};
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
returnToSearchParams,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Expense, unknown>;
approveClickHandler: () => void;
rejectClickHandler: () => void;
deleteClickHandler: () => void;
returnToSearchParams: URLSearchParams;
}) => {
const popoverId = `expense#${props.row.original.id}`;
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
const showEditButton = props.row.original.latest_approval
? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 ||
@@ -59,94 +79,127 @@ const RowOptionsMenu = ({
props.row.original.latest_approval.step_number === 4)
: false;
// TODO: apply RBAC
const showRealizationButton = props.row.original.latest_approval
? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4
: false;
return (
<RowOptionsMenuWrapper type={type}>
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<RequirePermission permissions='lti.expense.detail'>
<Button
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.expense.detail'>
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
href={buildExpenseActionHref(
'/expense/detail/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost'
color='warning'
className='justify-start text-sm'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
/>
Edit
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
)}
{showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<Button
href={buildExpenseActionHref(
'/expense/detail/edit/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit
</Button>
</RequirePermission>
)}
{showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
href={buildExpenseActionHref(
'/expense/realization/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon
icon='material-symbols:money-bag-rounded'
width={20}
height={20}
className='text-info'
/>
Realisasi
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.expense.delete'>
<Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='info'
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:money-bag-rounded'
width={16}
height={16}
/>
Realisasi
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.expense.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</div>
</RowOptionsMenuWrapper>
</div>
</PopoverContent>
</div>
);
};
const ExpensesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
} = useTableFilter<ExpenseTableFilters>({
initial: {
search: '',
page: Number(searchParams.get('page')) || 1,
pageSize: Number(searchParams.get('limit')) || 10,
search: searchValue,
nameSort: '',
transactionDate: '',
realizationDate: '',
@@ -175,10 +228,61 @@ const ExpensesTable = () => {
ExpenseApi.getAllFetcher
);
const syncPaginationToUrl = useCallback(
(page: number, pageSize: number) => {
const nextQueryString = new URLSearchParams({
page: String(page),
limit: String(pageSize),
}).toString();
router.replace(
nextQueryString ? `${pathname}?${nextQueryString}` : pathname,
{
scroll: false,
}
);
},
[pathname, router]
);
const pageChangeHandler = useCallback(
(page: number) => {
setPage(page);
syncPaginationToUrl(page, tableFilterState.pageSize);
},
[setPage, syncPaginationToUrl, tableFilterState.pageSize]
);
const pageSizeChangeHandler = useCallback(
(pageSize: number) => {
setPageSize(pageSize);
syncPaginationToUrl(1, pageSize);
},
[setPageSize, syncPaginationToUrl]
);
const returnToSearchParams = useMemo(() => {
const returnToParams = new URLSearchParams();
const queryString = new URLSearchParams({
page: String(tableFilterState.page),
limit: String(tableFilterState.pageSize),
}).toString();
returnToParams.set(
'returnTo',
queryString ? `${pathname}?${queryString}` : pathname
);
return returnToParams;
}, [pathname, tableFilterState.page, tableFilterState.pageSize]);
const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
undefined
);
@@ -340,31 +444,7 @@ const ExpensesTable = () => {
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
const approveClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
setApprovalNotes('');
approveModal.openModal();
};
const rejectClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
setApprovalNotes('');
rejectModal.openModal();
};
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedExpense(props.row.original);
@@ -372,31 +452,12 @@ const ExpensesTable = () => {
};
return (
<>
{currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 3 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
<RowOptionsMenu
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props}
deleteClickHandler={deleteClickHandler}
returnToSearchParams={returnToSearchParams}
/>
);
},
},
@@ -535,51 +596,37 @@ const ExpensesTable = () => {
setIsRejectLoading(false);
};
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
);
};
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const [selectedVendor, setSelectedVendor] = useState<OptionType | null>(null);
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedVendor(val as OptionType);
updateFilter('vendorId', val ? ((val as OptionType).value as string) : '');
};
useEffect(() => {
setTableState('expense-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const transactionDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
updateFilter('transactionDate', e.target.value);
// ===== FILTER MODAL HANDLERS =====
const handleFilterModalOpen = () => {
filterModal.openModal();
};
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
updateFilter('realizationDate', e.target.value);
const handleFilterSubmit = (values: {
transaction_date?: string | null;
realization_date?: string | null;
location_id?: string | null;
vendor_id?: string | null;
}) => {
updateFilter('transactionDate', values.transaction_date || '');
updateFilter('realizationDate', values.realization_date || '');
updateFilter('locationId', values.location_id || '');
updateFilter('vendorId', values.vendor_id || '');
};
const handleFilterReset = () => {
updateFilter('transactionDate', '');
updateFilter('realizationDate', '');
updateFilter('locationId', '');
updateFilter('vendorId', '');
};
// track sorting
@@ -587,7 +634,7 @@ const ExpensesTable = () => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
updateFilter('nameSort', '', false);
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
@@ -595,188 +642,193 @@ const ExpensesTable = () => {
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
<RequirePermission permissions='lti.expense.create'>
<div className='w-full'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.expense.create'>
<Button
href='/expense/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Expense
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && (
<>
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
href='/expense/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Expense
<Icon
icon='lucide-lab:farm'
width={20}
height={20}
className='text-success'
/>
Approve Head Area
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && (
<>
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
variant='outline'
color='info'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
className='w-full sm:w-fit'
>
<Icon icon='lucide-lab:farm' width={24} height={24} />
Approve Head Area
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='tdesign:money'
width={20}
height={20}
className='text-success'
/>
Approve Unit Vice President
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
className='w-full sm:w-fit'
>
<Icon icon='tdesign:money' width={24} height={24} />
Approve Unit Vice President
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.finance'>
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='tdesign:money'
width={20}
height={20}
className='text-success'
/>
Approve Finance
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.finance'>
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='w-full sm:w-fit'
>
<Icon icon='tdesign:money' width={24} height={24} />
Approve Finance
</Button>
</RequirePermission>
<RequirePermission
permissions={[
'lti.expense.approve.head_area',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance',
]}
>
<Button
variant='outline'
color='none'
onClick={bulkRejectClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnHeadArea &&
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='material-symbols:close'
width={20}
height={20}
className='text-error'
/>
Reject
</Button>
</RequirePermission>
</>
)}
</div>
<RequirePermission
permissions={[
'lti.expense.approve.head_area',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance',
]}
>
<Button
variant='outline'
color='error'
onClick={bulkRejectClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnHeadArea &&
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:close'
width={24}
height={24}
/>
Reject
</Button>
</RequirePermission>
</>
)}
</div>
</div>
{/* 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: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 className='grid grid-cols-12 justify-end gap-2'>
<DateInput
required
label='Tanggal Transaksi'
name='transaction_date'
placeholder='Masukkan tanggal transaksi'
value={tableFilterState.transactionDate}
onChange={transactionDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<DateInput
required
label='Tanggal Realisasi'
name='realization_date'
placeholder='Masukkan tanggal realisasi'
value={tableFilterState.realizationDate}
onChange={realizationDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Vendor'
options={vendorOptions}
isLoading={isLoadingVendorOptions}
value={selectedVendor}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
/>
</div>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'nameSort',
'userId',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
</div>
<Table<Expense>
data={isResponseSuccess(expenses) ? expenses?.data : []}
columns={expensesColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(expenses) && expenses?.data?.length === 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',
}}
/>
{/* 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(expenses) || expenses.data?.length === 0 ? (
<div className='p-3'>
<ExpenseTableSkeleton
columns={expensesColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Expense>
data={isResponseSuccess(expenses) ? expenses?.data : []}
columns={expensesColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={pageChangeHandler}
onPageSizeChange={pageSizeChangeHandler}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
@@ -831,6 +883,12 @@ const ExpensesTable = () => {
onClick: confirmationModalRejectClickHandler,
}}
/>
<ExpensesFilterModal
ref={filterModal.ref}
onSubmit={handleFilterSubmit}
onReset={handleFilterReset}
/>
</>
);
};
@@ -0,0 +1,28 @@
import * as yup from 'yup';
export type ExpensesFilterType = {
transaction_date: string | null;
realization_date: string | null;
location_id: string | null;
vendor_id: 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(),
});
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -0,0 +1,209 @@
'use client';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import {
ExpensesFilterSchema,
ExpensesFilterValues,
} from '@/components/pages/expense/filter/ExpensesFilter';
interface ExpensesFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: ExpensesFilterValues;
onSubmit?: (values: Partial<ExpensesFilterValues>) => void;
onReset?: () => void;
}
const ExpensesFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: ExpensesFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const formik = useFormik<ExpensesFilterValues>({
initialValues: initialValues || {
transaction_date: null,
realization_date: null,
location_id: null,
vendor_id: 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;
const vendorValue = formik.values.vendor_id
? vendorOptions.find(
(opt) => String(opt.value) === formik.values.vendor_id
) || null
: null;
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('location_id', locationId);
};
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
const vendorId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('vendor_id', vendorId);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full flex flex-col'
>
{/* Modal Header */}
<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>
{/* 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>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationValue}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
options={vendorOptions}
value={vendorValue}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<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 ExpensesFilterModal;
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
@@ -75,6 +75,12 @@ const ExpenseKandangsTable = ({
.filter((id): id is number => id !== undefined)
)
);
const rowSelectionRef = useRef(rowSelection);
const prevRowSelectionRef = useRef<Record<string, boolean>>({});
useEffect(() => {
rowSelectionRef.current = rowSelection;
}, [rowSelection]);
const kandangsColumns: ColumnDef<Kandang>[] = [
{
@@ -133,33 +139,43 @@ const ExpenseKandangsTable = ({
useEffect(() => {
setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false);
}, [kandangs, isResponseSuccess]);
}, [kandangs]);
useEffect(() => {
if (Object.keys(rowSelection).length !== 0 && isResponseSuccess(kandangs)) {
const formattedSelectedKandangs = Object.keys(rowSelection).map(
(item) => {
const selectedKandang = kandangs.data.find(
(kandang) => kandang.id === parseInt(item)
);
const currentKeys = Object.keys(rowSelection).sort().join(',');
const prevKeys = Object.keys(prevRowSelectionRef.current).sort().join(',');
return {
id: parseInt(item),
name: selectedKandang?.name ?? 'Kandang tidak ditemukan!',
};
}
);
if (currentKeys !== prevKeys) {
prevRowSelectionRef.current = { ...rowSelection };
onChange(formattedSelectedKandangs);
} else {
onChange([]);
if (
Object.keys(rowSelection).length !== 0 &&
isResponseSuccess(kandangs)
) {
const formattedSelectedKandangs = Object.keys(rowSelection).map(
(item) => {
const selectedKandang = kandangs.data.find(
(kandang) => kandang.id === parseInt(item)
);
return {
id: parseInt(item),
name: selectedKandang?.name ?? 'Kandang tidak ditemukan!',
};
}
);
onChange(formattedSelectedKandangs);
} else if (Object.keys(rowSelection).length === 0) {
onChange([]);
}
}
}, [rowSelection]);
}, [rowSelection, kandangs, onChange]);
useEffect(() => {
if (
selectedKandangs.length === 0 &&
Object.keys(rowSelection).length !== 0
Object.keys(rowSelectionRef.current).length !== 0
) {
setRowSelection({});
}
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react';
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,13 +86,14 @@ const ExpenseRealizationForm = ({
toast.success(updateExpenseRes?.message as string);
router.refresh();
router.push('/expense');
router.push(returnTo);
},
[router]
[returnTo, router]
);
const formik = useFormik<ExpenseRealizationFormValues>({
initialValues: getExpenseRealizationFormInitialValues(initialValues),
enableReinitialize: true,
validationSchema:
type === 'edit'
? UpdateExpenseRealizationFormSchema
@@ -143,7 +147,6 @@ const ExpenseRealizationForm = ({
},
});
const { setValues: formikSetValues } = formik;
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const {
@@ -207,7 +210,7 @@ const ExpenseRealizationForm = ({
// add new realizations for each kandang
kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return;
if (isNaN(Number(kandangItem.id))) return;
const existingRealization = formik.values.realizations?.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id
@@ -254,15 +257,11 @@ const ExpenseRealizationForm = ({
formik.setFieldValue('documents', newRequestDocuments);
};
useEffect(() => {
formikSetValues(getExpenseRealizationFormInitialValues(initialValues));
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
return (
<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'
>
@@ -35,6 +35,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(
NonstockApi.basePath,
'id',
@@ -164,6 +165,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
className={{ wrapper: 'min-w-48' }}
isDisabled
/>
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
@@ -102,6 +102,7 @@ const ExpenseRequestForm = ({
const formik = useFormik<ExpenseRequestFormValues>({
initialValues: getExpenseFormInitialValues(initialValues),
enableReinitialize: true,
validationSchema:
type === 'edit'
? UpdateExpenseRequestFormSchema
@@ -171,23 +172,25 @@ const ExpenseRequestForm = ({
},
});
const { setValues: formikSetValues } = formik;
const { setFieldValue, setFieldTouched } = formik;
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('category', true);
formik.setFieldValue('category', val);
setFieldTouched('category', true);
setFieldValue('category', val);
};
const locationChangeHandler = useCallback(
@@ -195,12 +198,12 @@ const ExpenseRequestForm = ({
const location = val as OptionType | null;
const locationId = location ? Number(location.value) : 0;
formik.setFieldTouched('location', true);
formik.setFieldValue('location', location);
formik.setFieldTouched('location_id', true);
formik.setFieldValue('location_id', locationId);
setFieldTouched('location', true);
setFieldValue('location', location);
setFieldTouched('location_id', true);
setFieldValue('location_id', locationId);
},
[]
[setFieldTouched, setFieldValue]
);
const kandangsChangeHandler = (
@@ -343,10 +346,6 @@ const ExpenseRequestForm = ({
formik.handleSubmit(e);
};
useEffect(() => {
formikSetValues(getExpenseFormInitialValues(initialValues));
}, [formikSetValues, getExpenseFormInitialValues, initialValues]);
return (
<>
<section className='w-full'>
@@ -411,6 +410,7 @@ const ExpenseRequestForm = ({
options={locationOptions}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
@@ -455,6 +455,7 @@ const ExpenseRequestForm = ({
options={supplierOptions}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreSuppliers}
isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
}
@@ -287,8 +287,8 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={ExpensePDFStyle.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
</Text>
<View style={ExpensePDFStyle.doubleDivider} />
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Expense } from '@/types/api/expense';
import { ColumnDef } from '@tanstack/react-table';
const ExpenseTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no expense data displayed. Enter expense data to get started.',
}: {
columns: ColumnDef<Expense>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default ExpenseTableSkeleton;
+20 -15
View File
@@ -2,7 +2,6 @@ import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import RequirePermission from '@/components/helper/RequirePermission';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
@@ -26,11 +25,13 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
const informasiUmum = [
{
label: 'ID',
value: finance.payment_code,
value: finance.payment_code || '-',
},
{
label: 'Jenis Transaksi',
value: formatTitleCase(finance.transaction_type.split('_').join(' ')),
value: formatTitleCase(
(finance.transaction_type || '').split('_').join(' ')
),
},
{
label: 'Pihak',
@@ -38,11 +39,13 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
},
{
label: 'Tanggal',
value: formatDate(finance.payment_date, 'DD MMM yyyy'),
value: finance.payment_date
? formatDate(finance.payment_date, 'DD MMM yyyy')
: '-',
},
{
label: 'Metode Pembayaran',
value: finance.payment_method,
value: finance.payment_method || '-',
},
{
label: 'Catatan',
@@ -61,22 +64,22 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
: '-',
},
{
label: `Rekening ${formatTitleCase(finance.party?.type)}`,
value: finance.party?.account_number,
label: `Rekening ${formatTitleCase(finance.party?.type || '')}`,
value: finance.party?.account_number || '-',
},
{
label: 'Nominal',
value: formatCurrency(
finance.transaction_type === 'INJECTION'
? finance.nominal
: Math.abs(finance.nominal)
? finance.nominal || 0
: Math.abs(finance.nominal || 0)
),
},
].filter((item) => {
// Hide party account number row if transaction type is INJECTION
if (
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
FINANCE_INJECTION_STATUS.includes(finance.transaction_type || '') &&
item.label === `Rekening ${formatTitleCase(finance.party?.type || '')}`
) {
return false;
}
@@ -150,7 +153,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Card>
<div className='flex flex-row gap-2 justify-end'>
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type || '') &&
finance.party?.type !== 'SUPPLIER' && (
<RequirePermission permissions='lti.finance.payments.update'>
<Button
@@ -163,7 +166,9 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Button>
</RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
{FINANCE_INITIAL_BALANCE_STATUS.includes(
finance.transaction_type || ''
) && (
<RequirePermission permissions='lti.finance.initial_balances.update'>
<Button
color='warning'
@@ -175,7 +180,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Button>
</RequirePermission>
)}
{FINANCE_INJECTION_STATUS.includes(finance.transaction_type) && (
{FINANCE_INJECTION_STATUS.includes(finance.transaction_type || '') && (
<RequirePermission permissions='lti.finance.injections.update'>
<Button
color='warning'
@@ -201,7 +206,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Finance ini (${finance?.payment_code})?`}
text={`Apakah anda yakin ingin menghapus data Finance ini (${finance?.payment_code || ''})?`}
secondaryButton={{
text: 'Tidak',
}}
+471 -270
View File
@@ -1,10 +1,19 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { CellContext } from '@tanstack/react-table';
'use client';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import Button from '@/components/Button';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, {
@@ -12,7 +21,6 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import Table from '@/components/Table';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance';
import {
@@ -25,115 +33,145 @@ import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { Icon } from '@iconify/react';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { useUiStore } from '@/stores/ui/ui.store';
import {
FinanceTableFilterSchema,
FinanceTableFilterValues,
} from './FinanceTableFilter.schema';
} from '@/components/pages/finance/filter/FinanceFilter';
import FinanceTableSkeleton from '@/components/pages/finance/skeleton/FinanceTableSkeleton';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Finance, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `finance#${props.row.original.id}`;
const popoverAnchorName = `--anchor-finance#${props.row.original.id}`;
const closePopover = () => {
const popover = document.getElementById(popoverId) as
| HTMLDivElement
| undefined;
popover?.hidePopover?.();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission
permissions={[
'lti.finance.transactions.detail',
'lti.finance.initial_balances.detail',
'lti.finance.injections.detail',
'lti.finance.payments.detail',
]}
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Button
href={`/finance/detail?financeId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
{FINANCE_TRANSACTION_STATUS.includes(
props.row.original.transaction_type
) && (
<RequirePermission permissions='lti.finance.payments.update'>
<Button
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission
permissions={[
'lti.finance.transactions.detail',
'lti.finance.initial_balances.detail',
'lti.finance.injections.detail',
'lti.finance.payments.detail',
]}
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
)}
<Button
href={`/finance/detail?financeId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
{FINANCE_INITIAL_BALANCE_STATUS.includes(
props.row.original.transaction_type
) && (
<RequirePermission permissions='lti.finance.initial_balances.update'>
<Button
href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
)}
{FINANCE_TRANSACTION_STATUS.includes(
props.row.original.transaction_type
) && (
<RequirePermission permissions='lti.finance.payments.update'>
<Button
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit
</Button>
</RequirePermission>
)}
{FINANCE_INJECTION_STATUS.includes(
props.row.original.transaction_type
) && (
<RequirePermission permissions='lti.finance.injections.update'>
<Button
href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes(
props.row.original.transaction_type
) && (
<RequirePermission permissions='lti.finance.initial_balances.update'>
<Button
href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.finance.transactions.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
{FINANCE_INJECTION_STATUS.includes(
props.row.original.transaction_type
) && (
<RequirePermission permissions='lti.finance.injections.update'>
<Button
href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.finance.transactions.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
@@ -171,6 +209,9 @@ const FinanceTable = () => {
},
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== State =====
const deleteModal = useModal();
const [selectedTransactionType, setSelectedTransactionType] = useState<
@@ -189,6 +230,7 @@ const FinanceTable = () => {
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== Formik for Filter =====
const filterFormik = useFormik<FinanceTableFilterValues>({
@@ -214,6 +256,18 @@ const FinanceTable = () => {
updateFilter('sortBy', values.sort_by);
updateFilter('startDate', values.start_date);
updateFilter('endDate', values.end_date);
filterModal.closeModal();
},
onReset: () => {
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
},
});
@@ -266,10 +320,41 @@ 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 = (e: React.ChangeEvent<HTMLInputElement>) => {
filterFormik.setFieldValue('search', e.target.value);
};
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
@@ -335,10 +420,7 @@ const FinanceTable = () => {
const endDateObj = new Date(endDate);
if (endDateObj < startDate) {
filterFormik.setFieldError(
'end_date',
'Tanggal akhir tidak boleh masa lampau'
);
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
@@ -346,12 +428,14 @@ const FinanceTable = () => {
setDateErrorShown(true);
}
} else {
filterFormik.setFieldError('end_date', undefined);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
@@ -366,10 +450,7 @@ const FinanceTable = () => {
const endDate = new Date(value);
if (endDate < startDateObj) {
filterFormik.setFieldError(
'end_date',
'Tanggal akhir tidak boleh masa lampau'
);
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
@@ -380,13 +461,18 @@ const FinanceTable = () => {
}
}
filterFormik.setFieldError('end_date', undefined);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const handleFilterModalOpen = () => {
filterModal.openModal();
filterFormik.validateForm();
};
const resetFilterHandler = () => {
setSelectedTransactionType(null);
setSelectedBank(null);
@@ -406,6 +492,7 @@ const FinanceTable = () => {
updateFilter('startDate', '');
updateFilter('endDate', '');
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -417,8 +504,8 @@ const FinanceTable = () => {
setIsDeleteLoading(false);
};
const columns = useMemo(() => {
return [
const columns: ColumnDef<Finance>[] = useMemo(
() => [
{
header: 'ID',
accessorKey: 'payment_code',
@@ -498,32 +585,17 @@ const FinanceTable = () => {
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
];
}, []);
],
[deleteModal]
);
useEffect(() => {
return () => {
@@ -555,151 +627,280 @@ const FinanceTable = () => {
}, [resetSearchValue, dateErrorShown]);
return (
<section className='size-full flex flex-col gap-6'>
<div className='flex justify-end gap-2'>
<RequirePermission permissions='lti.finance.injections.create'>
<Button
color='warning'
className='min-w-24'
href='/finance/add/injection'
>
Injection Saldo Bank
</Button>
</RequirePermission>
<RequirePermission permissions='lti.finance.initial_balances.create'>
<Button
color='info'
className='text-white min-w-24'
href='/finance/add/initial-balance'
>
Saldo Awal
</Button>
</RequirePermission>
<RequirePermission permissions='lti.finance.payments.create'>
<Button color='primary' className='min-w-24' href='/finance/add'>
Tambah
</Button>
</RequirePermission>
</div>
<Card
variant='bordered'
className={{
wrapper: 'w-full',
}}
footer={
<div className='flex justify-end gap-2'>
<>
<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.finance.injections.create'>
<Button
href='/finance/add/injection'
color='warning'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='mdi:bank-transfer-in' width={20} height={20} />
Add Injection (Saldo Bank)
</Button>
</RequirePermission>
<RequirePermission permissions='lti.finance.initial_balances.create'>
<Button
href='/finance/add/initial-balance'
color='info'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='mdi:cash-register' width={20} height={20} />
Add Initial Balance
</Button>
</RequirePermission>
<RequirePermission permissions='lti.finance.payments.create'>
<Button
href='/finance/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 Finance
</Button>
</RequirePermission>
</div>
{/* 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: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',
}}
/>
<Button
color='warning'
className='min-w-24'
onClick={resetFilterHandler}
variant='outline'
color='none'
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,
}
)}
>
Reset
</Button>
<Button
color='primary'
className='min-w-24'
onClick={() => filterFormik.handleSubmit()}
>
Cari
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
</div>
}
>
<div className='grid grid-cols-4 gap-6'>
<SelectInput
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
label='Jenis Transaksi'
value={selectedTransactionType}
onChange={transactionTypeChangeHandler}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={customerOptions}
label={'Customer'}
value={selectedCustomerId}
onChange={customerIdChangeHandler}
onInputChange={customerInputValue}
onMenuScrollToBottom={customerLoadMore}
isLoading={customerIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={supplierOptions}
label={'Supplier'}
value={selectedSupplierId}
onChange={supplierIdChangeHandler}
onInputChange={supplierInputValue}
onMenuScrollToBottom={supplierLoadMore}
isLoading={supplierIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={bankSelectOptions}
label='Bank'
value={selectedBank}
onChange={bankChangeHandler}
onInputChange={bankInputValue}
onMenuScrollToBottom={bankLoadMore}
closeMenuOnSelect={false}
isClearable
isMulti
/>
<SelectInput
options={sortByOptions}
label='Urutkan Berdasarkan'
value={selectedSortBy}
onChange={sortByChangeHandler}
isClearable
/>
<DateInput
name='start_date'
label='Periode Tanggal (Mulai)'
value={filterFormik.values.start_date}
onChange={startDateChangeHandler}
errorMessage={
filterFormik.errors.end_date
? filterFormik.errors.end_date
: undefined
}
/>
<DateInput
name='end_date'
label='Periode Tanggal (Akhir)'
value={filterFormik.values.end_date}
onChange={endDateChangeHandler}
errorMessage={
filterFormik.errors.end_date
? filterFormik.errors.end_date
: undefined
}
/>
<DebouncedTextInput
name='search'
label='Cari'
placeholder='Cari'
value={filterFormik.values.search}
onChange={searchChangeHandler}
/>
</div>
</Card>
<Table<Finance>
data={isResponseSuccess(finances) ? finances.data : []}
columns={columns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page}
onPageChange={setPage}
onPageSizeChange={setPageSize}
totalItems={
isResponseSuccess(finances) ? finances.meta?.total_results : 0
}
isLoading={isLoading}
/>
{/* 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(finances) || finances.data?.length === 0 ? (
<div className='p-3'>
<FinanceTableSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Finance>
data={isResponseSuccess(finances) ? finances.data : []}
columns={columns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page}
totalItems={
isResponseSuccess(finances) ? finances.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-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={filterFormik.handleSubmit}
onReset={filterFormik.handleReset}
>
<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='start_date'
placeholder='Periode Tanggal Awal'
value={filterFormik.values.start_date}
errorMessage={filterFormik.errors.start_date}
onChange={startDateChangeHandler}
isError={
filterFormik.touched.start_date &&
Boolean(filterFormik.errors.start_date)
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
placeholder='Periode Tanggal Akhir'
value={filterFormik.values.end_date}
errorMessage={filterFormik.errors.end_date}
onChange={endDateChangeHandler}
isError={
(filterFormik.touched.end_date &&
Boolean(filterFormik.errors.end_date)) ||
hasDateError
}
/>
</div>
</div>
<SelectInput
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
label='Jenis Transaksi'
placeholder='Pilih Jenis Transaksi'
value={selectedTransactionType}
onChange={transactionTypeChangeHandler}
onInputChange={() => {}}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={customerOptions}
label='Customer'
placeholder='Pilih Customer'
value={selectedCustomerId}
onChange={customerIdChangeHandler}
onInputChange={customerInputValue}
onMenuScrollToBottom={customerLoadMore}
isLoading={customerIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={supplierOptions}
label='Supplier'
placeholder='Pilih Supplier'
value={selectedSupplierId}
onChange={supplierIdChangeHandler}
onInputChange={supplierInputValue}
onMenuScrollToBottom={supplierLoadMore}
isLoading={supplierIsLoadingOptions}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={bankSelectOptions}
label='Bank'
placeholder='Pilih Bank'
value={selectedBank}
onChange={bankChangeHandler}
onInputChange={bankInputValue}
onMenuScrollToBottom={bankLoadMore}
closeMenuOnSelect={false}
isClearable
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInput
options={sortByOptions}
label='Urutkan Berdasarkan'
placeholder='Pilih Urutan'
value={selectedSortBy}
onChange={sortByChangeHandler}
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='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={() => {
filterFormik.resetForm();
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
resetFilterHandler();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!filterFormik.isValid || filterFormik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
@@ -714,7 +915,7 @@ const FinanceTable = () => {
onClick: confirmationModalDeleteClickHandler,
}}
/>
</section>
</>
);
};
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Finance } from '@/types/api/finance/finance';
import { ColumnDef } from '@tanstack/react-table';
const FinanceTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no finance data displayed. Enter finance data to get started.',
}: {
columns: ColumnDef<Finance>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default FinanceTableSkeleton;
@@ -1,22 +1,108 @@
'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR, { mutate } from 'swr';
import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import { useFormik } from 'formik';
import Button from '@/components/Button';
import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Modal, { useModal } from '@/components/Modal';
import { isResponseSuccess } from '@/lib/api-helper';
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';
import toast from 'react-hot-toast';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
import { Product } from '@/types/api/master-data/product';
import StatusBadge from '@/components/helper/StatusBadge';
import InventoryAdjustmentTableSkeleton from '@/components/pages/inventory/adjustment/skeleton/InventoryAdjustmentTableSkeleton';
import {
AdjustmentFilterSchema,
AdjustmentFilterType,
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { CellContext } from '@tanstack/react-table';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<InventoryAdjustment, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `adjustment#${props.row.original.id}`;
const popoverAnchorName = `--anchor-adjustment#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -30,6 +116,9 @@ const InventoryAdjustmentTable = () => {
productSort: '',
warehouseSort: '',
stockSort: '',
productFilter: '',
warehouseFilter: '',
transactionTypeFilter: '',
},
paramMap: {
page: 'page',
@@ -38,84 +127,303 @@ const InventoryAdjustmentTable = () => {
productSort: 'sort_product',
warehouseSort: 'sort_warehouse',
stockSort: 'sort_stock',
productFilter: 'product_id',
warehouseFilter: 'warehouse_id',
transactionTypeFilter: 'transaction_type',
},
});
// Fetch Data
const { data: inventoryAdjustments, isLoading } = useSWR(
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<AdjustmentFilterType>({
initialValues: {
product_id: null,
warehouse: null,
transaction_type: null,
},
validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', String(values.warehouse?.value) || '');
updateFilter('transactionTypeFilter', values.transaction_type || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
updateFilter('transactionTypeFilter', '');
},
});
// ===== PRODUCT OPTIONS =====
const {
setInputValue: setProductInputValue,
options: productOptions,
isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts,
} = useSelect<Product>(
filterModal.open ? ProductApi.basePath : null,
'id',
'name',
'search'
);
// ===== WAREHOUSE OPTIONS =====
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(
filterModal.open ? WarehouseApi.basePath : null,
'id',
'name',
'search'
);
// ===== TRANSACTION TYPE OPTIONS =====
const transactionTypeOptions = useMemo(() => {
return [
{ value: 'increase', label: 'Increase' },
{ value: 'decrease', label: 'Decrease' },
];
}, []);
// ===== 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 handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
) => {
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]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
const {
data: inventoryAdjustments,
isLoading,
mutate: refreshAdjustments,
} = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher
);
// State
const [sorting, setSorting] = useState<SortingState>([]);
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
// Columns
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
id: 'product_name',
header: 'Nama Produk',
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
},
{
id: 'warehouse_name',
header: 'Gudang',
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
},
{
id: 'created_at',
header: 'Tanggal',
accessorFn: (row) =>
new Date(row.created_at).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
{
id: 'quantity',
header: 'Kuantitas',
accessorFn: (row) => formatNumber(String(row.increase + row.decrease)),
},
{
id: 'transaction_type',
header: 'Tipe Transaksi',
accessorFn: (row) => {
if (row.increase > 0) return 'Peningkatan';
if (row.decrease > 0) return 'Penurunan';
return '-';
},
cell: (props) => {
const type = props.row.original.increase;
const label = type > 0 ? 'Peningkatan' : type <= 0 ? 'Penurunan' : '-';
const response = await InventoryAdjustmentApi.delete(
selectedAdjustment?.id as number
);
return (
<Badge variant='soft' color={type > 0 ? 'success' : 'error'}>
{label}
</Badge>
);
},
},
{
id: 'created_by',
header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-',
},
];
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
// Handler
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Adjustment!');
refreshAdjustments();
} else {
toast.error(response?.message || 'Failed to delete Adjustment');
}
};
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedAdjustment, setSelectedAdjustment] = useState<
InventoryAdjustment | undefined
>(undefined);
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);
};
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
() => [
{
id: 'adj_number',
header: 'No. Referensi',
accessorFn: (row) => row.adj_number ?? '-',
},
{
id: 'location',
header: 'Lokasi',
accessorFn: (row) => row.location?.name ?? '-',
},
{
id: 'project_flock',
header: 'Flock',
accessorFn: (row) => row.project_flock?.flock_name ?? '-',
},
{
id: 'warehouse_name',
header: 'Gudang',
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
},
{
id: 'product_name',
header: 'Nama Produk',
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
},
{
id: 'quantity',
header: 'Kuantitas',
accessorFn: (row) => row.qty ?? '-',
cell: (row) => {
const value = row.row.original.increase + row.row.original.decrease;
return <div className='text-center'>{formatNumber(value)}</div>;
},
},
{
id: 'price',
header: 'Harga',
accessorFn: (row) => (row.price ? formatCurrency(row.price) : '-'),
},
{
id: 'grand_total',
header: 'Grand Total',
accessorFn: (row) =>
row.grand_total ? formatCurrency(row.grand_total) : '-',
},
{
id: 'transaction_type',
header: 'Tipe Transaksi',
accessorFn: (row) => row.transaction_subtype ?? '-',
cell: (row) => {
const subtype = row.row.original.transaction_subtype;
const increase = row.row.original.increase;
const getSubtypeLabel = (subtypeValue: string): string => {
if (subtypeValue === TRANSACTION_SUBTYPE_OPTIONS.PEMBELIAN.value) {
return TRANSACTION_SUBTYPE_OPTIONS.PEMBELIAN.label;
}
if (subtypeValue === TRANSACTION_SUBTYPE_OPTIONS.PENJUALAN.value) {
return TRANSACTION_SUBTYPE_OPTIONS.PENJUALAN.label;
}
const recordingOption = TRANSACTION_SUBTYPE_OPTIONS.RECORDING.find(
(opt) => opt.value === subtypeValue
);
if (recordingOption) {
return recordingOption.label;
}
if (subtypeValue === 'RECORDING_DEPLETION_OUT') {
return 'Recording Depletion';
}
return subtypeValue || '-';
};
const label = getSubtypeLabel(subtype);
return (
<StatusBadge
color={
increase > 0 ? 'success' : increase <= 0 ? 'error' : 'neutral'
}
text={label}
className={{
badge: 'whitespace-nowrap',
}}
/>
);
},
},
{
id: 'created_at',
header: 'Tanggal',
accessorFn: (row) =>
row.created_at ? formatDate(row.created_at, 'DD MMM YYYY') : '-',
},
{
id: 'created_by',
header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-',
},
{
id: 'actions',
header: 'Aksi',
cell: (props: CellContext<InventoryAdjustment, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedAdjustment(props.row.original);
singleDeleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
],
[
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedAdjustment,
]
);
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
@@ -130,7 +438,6 @@ const InventoryAdjustmentTable = () => {
[updateFilter]
);
// Effect
useEffect(() => {
const productCategorySortFilter = sorting.find(
(sortItem) => sortItem.id === 'productCategory'
@@ -149,88 +456,215 @@ const InventoryAdjustmentTable = () => {
updateSortingFilter('stockSort', stockSortFilter);
}, [sorting, updateSortingFilter]);
// Utils Function
const formatNumber = (value: string) => {
const numericValue = value.replace(/[^0-9.]/g, '');
const [integer, decimal] = numericValue.split('.');
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return decimal ? `${formattedInteger}.${decimal}` : formattedInteger;
};
// Render
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.inventory.create'>
<Button
href='/inventory/adjustment/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'min-w-28' }}
/>
</div>
<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.create'>
<Button
href='/inventory/adjustment/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 Adjustment
</Button>
</RequirePermission>
</div>
<Table<InventoryAdjustment>
data={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.data
: []
}
columns={inventoryAdjustmentsColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(inventoryAdjustments) &&
inventoryAdjustments?.data?.length === 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',
}}
/>
{/* 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: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',
'productCategorySort',
'productSort',
'warehouseSort',
'stockSort',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</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>
) : !isResponseSuccess(inventoryAdjustments) ||
inventoryAdjustments.data?.length === 0 ? (
<div className='p-3'>
<InventoryAdjustmentTableSkeleton
columns={inventoryAdjustmentsColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<InventoryAdjustment>
data={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.data
: []
}
columns={inventoryAdjustmentsColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.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>
{/* 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={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
isClearable
onMenuScrollToBottom={loadMoreProducts}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={formik.values.warehouse}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
isClearable
onMenuScrollToBottom={loadMoreWarehouses}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Tipe Transaksi'
placeholder='Pilih Tipe Transaksi'
options={transactionTypeOptions}
value={transactionTypeValue}
onChange={handleFilterTransactionTypeChange}
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='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();
}}
>
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>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Adjustment ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</>
);
};
@@ -0,0 +1,14 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const AdjustmentFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
transaction_type: string().nullable(),
});
export type AdjustmentFilterType = {
product_id: string | null;
transaction_type: string | null;
warehouse: OptionType<number> | null;
};
@@ -1,55 +1,118 @@
import * as Yup from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const InventoryAdjustmentFormSchema = Yup.object({
product_category: Yup.mixed<OptionType>()
.nullable()
.test(
'is-valid-option',
'Kategori Produk wajib diisi!',
(value) => value !== null && value !== undefined
export type InventoryAdjustmentFormSchemaType = {
location: {
value: number;
label: string;
} | null;
location_id: number;
project_flock: {
value: number;
label: string;
} | null;
project_flock_id: number;
kandang: {
value: number;
label: string;
} | null;
kandang_id: number;
project_flock_kandang: {
value: number;
label: string;
} | null;
project_flock_kandang_id: number;
product: {
value: number;
label: string;
} | null;
product_id: number;
depletion_product: {
value: number;
label: string;
} | null;
depletion_product_id: number;
transaction_type: string;
transaction_subtype: string;
qty: number | string;
price: number | string;
notes: string;
};
export const InventoryAdjustmentFormSchema: Yup.ObjectSchema<InventoryAdjustmentFormSchemaType> =
Yup.object({
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
location_id: Yup.number()
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'),
project_flock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_id: Yup.number()
.min(1, 'Project flock wajib diisi!')
.required('Project flock wajib diisi!')
.typeError('Project flock wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!')
.typeError('Kandang wajib diisi!'),
project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Project Flock Kandang wajib diisi!'),
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!')
.typeError('Produk wajib diisi!'),
depletion_product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
depletion_product_id: Yup.number()
.default(0)
.typeError('Jenis deplesi harus berupa angka'),
transaction_type: Yup.string()
.min(1, 'Tipe transaksi wajib diisi!')
.oneOf(
['PEMBELIAN', 'PENJUALAN', 'RECORDING'],
'Tipe transaksi tidak valid'
)
.required('Tipe transaksi wajib diisi')
.typeError('Tipe transaksi wajib diisi!'),
transaction_subtype: Yup.string().required(
'Sub tipe transaksi wajib diisi'
),
product_category_id: Yup.number().nullable(),
product: Yup.mixed<OptionType>()
.nullable()
.test(
'is-valid-option',
'Produk wajib diisi!',
(value) => value !== null && value !== undefined
),
product_id: Yup.number()
.nullable()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!'),
warehouse: Yup.mixed<OptionType>()
.nullable()
.test(
'is-valid-option',
'Warehouse wajib diisi!',
(value) => value !== null && value !== undefined
),
warehouse_id: Yup.number()
.nullable()
.required('Warehouse wajib diisi!')
.min(1, 'Warehouse wajib diisi!'),
transaction_type: Yup.string()
.oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid')
.nullable()
.required('Tipe transaksi wajib diisi'),
quantity: Yup.number()
.typeError('Kuantitas harus berupa angka')
.min(1, 'Minimal kuantitas adalah 1')
.required('Kuantitas wajib diisi'),
note: Yup.string().required('Catatan wajib diisi!'),
});
qty: Yup.number()
.typeError('Kuantitas harus berupa angka')
.min(1, 'Minimal kuantitas adalah 1')
.required('Kuantitas wajib diisi'),
price: Yup.number()
.typeError('Harga harus berupa angka')
.min(0, 'Minimal harga adalah 0')
.required('Harga wajib diisi'),
notes: Yup.string().required('Catatan wajib diisi!'),
});
export type InventoryAdjustmentFormValues = Yup.InferType<
typeof InventoryAdjustmentFormSchema
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { ColumnDef } from '@tanstack/react-table';
const InventoryAdjustmentTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no inventory adjustment data displayed. Enter inventory adjustment data to get started.',
}: {
columns: ColumnDef<InventoryAdjustment>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default InventoryAdjustmentTableSkeleton;
@@ -1,49 +1,116 @@
'use client';
import { ChangeEventHandler, useState } from 'react';
import useSWR from 'swr';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR, { mutate } from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik';
import Table from '@/components/Table';
import { Icon } from '@iconify/react';
import { Movement } from '@/types/api/inventory/movement';
import { MovementApi } from '@/services/api/inventory';
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 { ROWS_OPTIONS } from '@/config/constant';
import { OptionType } from '@/components/input/SelectInput';
import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Modal, { useModal } from '@/components/Modal';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import MovementTableSkeleton from '@/components/pages/inventory/movement/skeleton/MovementTableSkeleton';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Product } from '@/types/api/master-data/product';
import {
MovementFilterSchema,
MovementFilterType,
} from '@/components/pages/inventory/movement/filter/MovementFilter';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Movement, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.inventory.transfer.detail'>
<Button
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
deleteClickHandler: () => void;
}) => {
const popoverId = `movement#${props.row.original.id}`;
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='primary'
className='justify-start text-sm'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
);
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.transfer.detail'>
<Button
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.inventory.transfer.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const MovementTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -53,167 +120,410 @@ const MovementTable = () => {
} = useTableFilter({
initial: {
search: '',
productFilter: '',
warehouseFilter: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
productFilter: 'product_id',
warehouseFilter: 'warehouse_id',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
const { data: movements, isLoading } = useSWR(
// ===== FORMIK SETUP =====
const formik = useFormik<MovementFilterType>({
initialValues: {
product_id: null,
warehouse_id: null,
},
validationSchema: MovementFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
},
});
// ===== PRODUCT OPTIONS =====
const {
setInputValue: setProductInputValue,
options: productOptions,
isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts,
} = useSelect<Product>(
filterModal.open ? ProductApi.basePath : null,
'id',
'name',
'search'
);
// ===== WAREHOUSE OPTIONS =====
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(
filterModal.open ? WarehouseApi.basePath : null,
'id',
'name',
'search'
);
// ===== 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 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]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
const {
data: movements,
isLoading,
mutate: refreshMovements,
} = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher
);
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await MovementApi.delete(selectedMovement?.id as number);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Movement!');
refreshMovements();
} else {
toast.error(response?.message || 'Failed to delete Movement');
}
};
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);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
};
const movementColumns: ColumnDef<Movement>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorFn: (row) => row.source_warehouse?.name,
header: 'Gudang Asal',
},
{
accessorFn: (row) => row.destination_warehouse?.name,
header: 'Gudang Tujuan',
},
{
accessorKey: 'transfer_reason',
header: 'Catatan',
},
{
accessorKey: 'transfer_date',
header: 'Tanggal',
cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'),
},
{
accessorFn: (row) => {
const totalCost = row.deliveries?.reduce(
(sum, d) => sum + (d.shipping_cost_total || 0),
0
);
return totalCost?.toLocaleString('id-ID');
const movementColumns: ColumnDef<Movement>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
header: 'Biaya Pengiriman',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
{
accessorFn: (row) => row.source_warehouse?.name,
header: 'Gudang Asal',
},
},
];
{
accessorFn: (row) => row.destination_warehouse?.name,
header: 'Gudang Tujuan',
},
{
accessorKey: 'transfer_reason',
header: 'Catatan',
},
{
accessorKey: 'transfer_date',
header: 'Tanggal',
cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString(
'id-ID'
),
},
{
accessorFn: (row) => {
const totalCost = row.deliveries?.reduce(
(sum, d) => sum + (d.shipping_cost_total || 0),
0
);
return totalCost?.toLocaleString('id-ID');
},
header: 'Biaya Pengiriman',
},
{
header: 'Aksi',
cell: (props: CellContext<Movement, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedMovement(props.row.original);
singleDeleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
],
[
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedMovement,
]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'>
<RequirePermission permissions='lti.inventory.transfer.create'>
<Button
href='/inventory/movement/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 Movement
</Button>
</RequirePermission>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Movement'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<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.transfer.create'>
<Button
href='/inventory/movement/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 Movement
</Button>
</RequirePermission>
</div>
<div className='flex justify-end gap-4'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
{/* 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:magnifying-glass'
width={20}
height={20}
/>
}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
</div>
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={movementColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(movements) && movements?.data?.length === 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',
}}
/>
{/* 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(movements) || movements.data?.length === 0 ? (
<div className='p-3'>
<MovementTableSkeleton
columns={movementColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={movementColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={
isResponseSuccess(movements)
? movements?.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>
{/* 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={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={productIdValue}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
isClearable
onMenuScrollToBottom={loadMoreProducts}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={warehouseIdValue}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
isClearable
onMenuScrollToBottom={loadMoreWarehouses}
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='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();
}}
>
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>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</>
);
};
@@ -0,0 +1,11 @@
import { string, object } from 'yup';
export const MovementFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
});
export type MovementFilterType = {
product_id: string | null;
warehouse_id: string | null;
};
@@ -82,6 +82,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: number;
warehouse_name: string;
quantity: number;
transfer_available_qty?: number;
}
// ===== USE SELECT HOOKS =====
@@ -379,6 +380,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString()
: '',
transfer_context: 'inventory_transfer',
stock_mode: 'exclude_chickin',
}
);
@@ -391,6 +394,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: pw.warehouse.id,
warehouse_name: pw.warehouse.name,
quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
}))
: [];
}, [productWarehouses]);
@@ -834,6 +838,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, [formik.values.products, formik.values.deliveries]);
const getAvailableStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return (
productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
},
[productWarehouseOptions, type]
);
const getTotalStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
@@ -844,6 +864,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[productWarehouseOptions, type]
);
const hasAvailableQty = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.transfer_available_qty !== undefined;
},
[productWarehouseOptions]
);
const getProductQtyBottomLabel = useCallback(
(productIdx: number) => {
if (type === 'detail') return undefined;
@@ -851,16 +881,31 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return undefined;
const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0;
const remainingStock = availableStock - requestedQty;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > 0) {
if (isAyamProduct) {
return `Sisa: ${formatNumber(remainingStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Sisa: ${formatNumber(remainingStock)}`;
}
if (isAyamProduct) {
return `Tersedia: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Tersedia: ${formatNumber(availableStock)}`;
},
[formik.values.products, getAvailableStock, type]
[
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
);
const getDeliveryProductQtyBottomLabel = useCallback(
@@ -922,15 +967,26 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return null;
const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > availableStock) {
if (isAyamProduct) {
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)}, terpakai untuk chickin: ${formatNumber(totalStock - availableStock)})`;
}
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`;
}
return null;
},
[formik.values.products, getAvailableStock, type]
[
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
);
const validateDeliveryQty = useCallback(
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Movement } from '@/types/api/inventory/movement';
import { ColumnDef } from '@tanstack/react-table';
const MovementTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no movement data displayed. Enter movement data to get started.',
}: {
columns: ColumnDef<Movement>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default MovementTableSkeleton;
@@ -2,46 +2,78 @@
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryProduct } from '@/types/api/inventory/product';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<InventoryProduct, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.inventory.product_stock.detail'>
<Button
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
}) => {
const popoverId = `product#${props.row.original.id}`;
const popoverAnchorName = `--anchor-product#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='primary'
className='justify-start text-sm'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
);
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.product_stock.detail'>
<Button
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const InventoryProductTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -65,20 +97,23 @@ const InventoryProductTable = () => {
InventoryProductApi.getAllFetcher
);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
useEffect(() => {
setTableState('inventory-product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const columns: ColumnDef<InventoryProduct>[] = useMemo(
() => [
{
header: '#',
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
@@ -125,7 +160,7 @@ const InventoryProductTable = () => {
},
{
header: 'Aksi',
cell: (props) => {
cell: (props: CellContext<InventoryProduct, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
@@ -135,96 +170,108 @@ const InventoryProductTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
],
[]
[tableFilterState.pageSize, tableFilterState.page]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'></div>
</div>
<div className='flex justify-between items-end gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Produk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
<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>
<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}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(inventoryProducts) &&
inventoryProducts?.data?.length === 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',
}}
/>
{/* 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>
) : !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>
);
};
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { InventoryProduct } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
const InventoryProductTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no inventory product data displayed. Enter inventory product data to get started.',
}: {
columns: ColumnDef<InventoryProduct>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default InventoryProductTableSkeleton;
@@ -1,7 +1,6 @@
'use client';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
@@ -112,16 +111,34 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
useState<DeliveryOrderProductFormValues | null>(null);
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[]
>(
isResponseSuccess(marketing)
? mergeSOwithDO(
marketing?.data.sales_order?.map(SalesProductToFieldValues) ?? [],
marketing?.data.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(marketing.data.sales_order, delivery)
) ?? [],
true
)
: []
>([]);
const getDeliveryOrderValues = useCallback(
(marketingData: Marketing): DeliveryOrderProductFormValues[] => {
const hasDeliveryOrder =
marketingData.delivery_order &&
marketingData.delivery_order.length > 0 &&
marketingData.delivery_order.some(
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
);
if (hasDeliveryOrder) {
return (
marketingData.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(marketingData.sales_order, delivery)
) ?? []
);
}
return mergeSOwithDO(
marketingData.sales_order?.map(SalesProductToFieldValues) ?? [],
marketingData.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(marketingData.sales_order, delivery)
) ?? [],
true
);
},
[]
);
// ================== SETUP FORMIK ==================
@@ -130,14 +147,8 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
>(() => {
if (!isResponseSuccess(marketing))
return {} as SalesOrderFormValues & DeliveryOrderFormValues;
const deliveryValues = mergeSOwithDO(
marketing?.data.sales_order?.map(SalesProductToFieldValues) ?? [],
marketing?.data.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(marketing.data.sales_order, delivery)
) ?? [],
true
);
const deliveryValues = getDeliveryOrderValues(marketing.data);
setDeliveryOrderValues(deliveryValues);
return {
@@ -163,7 +174,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
) ?? [],
delivery_order: deliveryValues,
};
}, [marketing]);
}, [marketing, getDeliveryOrderValues]);
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
enableReinitialize: true,
@@ -188,6 +199,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
};
}
})
@@ -357,7 +371,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
const currentProducts = deliveryOrderValues?.find(
(product) => product.id == id
);
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
setSelectedDeliveryProduct(currentProducts ?? values ?? null);
if (id) {
setStep(2);
}
@@ -419,6 +435,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
};
}
})
@@ -648,9 +667,8 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
<tr>
<td className='text-sm px-4 py-3'>No. Order</td>
<td className='text-sm px-4 py-3'>
{marketing.data.do_number
? marketing.data.do_number
: marketing.data.so_number}
{marketing.data.do_number ||
marketing.data.so_number}
</td>
</tr>
<tr>
@@ -765,6 +783,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
<MemoizedDeliveryOrderProductForm
formState={'edit'}
salesOrders={marketing?.data?.sales_order ?? []}
deliveryOrders={marketing?.data?.delivery_order ?? []}
exisitingValues={deliveryOrderValues}
onSubmitForm={handleAddSubmitDO}
initialValues={selectedDeliveryProduct ?? undefined}
@@ -10,9 +10,14 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
MarketingFilterFormValues,
MarketingFilterSchema,
} from '@/components/pages/marketing/filter/MarketingFilter';
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
@@ -37,9 +42,12 @@ const MarketingFilterModal = ({
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect<BaseMarketing>(MarketingApi.basePath, 'id', 'so_number', '', {
limit: 'limit',
});
} = useSelect<BaseMarketing>(
MarketingApi.basePath,
'id',
'so_number',
'search'
);
const productsOptions = useMemo(() => {
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
@@ -66,19 +74,10 @@ const MarketingFilterModal = ({
isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(MarketingApi.basePath, 'customer.id', 'customer.name', '', {
limit: 'limit',
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search', {
has_marketing: 'true',
});
const uniqueCustomersOptions = useMemo(() => {
const seen = new Set();
return customersOptions.filter((customer) => {
if (seen.has(customer.value)) return false;
seen.add(customer.value);
return true;
});
}, [customersOptions]);
const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
@@ -87,23 +86,19 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' },
];
const formik = useFormik<{
product_ids: OptionType[];
status: OptionType | null;
customer_id: OptionType | null;
}>({
const formik = useFormik<MarketingFilterFormValues>({
initialValues: {
product_ids: [],
status: null,
customer_id: null,
customer: null,
},
validationSchema: MarketingFilterSchema,
onSubmit: async (values) => {
const formattedValues = {
...values,
const formattedValues: MarketingFilter = {
product_ids: values.product_ids.map((item) => Number(item.value)),
status: values.status?.value.toString() || '',
customer_id: Number(values.customer_id?.value),
customer_id: Number(values.customer?.value),
};
onSubmit?.(formattedValues);
@@ -121,7 +116,10 @@ const MarketingFilterModal = ({
};
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', val as OptionType);
formik.setFieldValue(
'customer',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -187,9 +185,9 @@ const MarketingFilterModal = ({
label='Customer'
isClearable
placeholder='Pilih customer'
options={uniqueCustomersOptions}
options={customersOptions}
isLoading={isLoadingCustomersOptions}
value={formik.values.customer_id}
value={formik.values.customer}
onChange={customerChangeHandler}
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
@@ -30,6 +30,8 @@ import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import StatusBadge from '@/components/helper/StatusBadge';
import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter';
import ButtonFilter from '@/components/helper/ButtonFilter';
import MarketingTableSkeleton from '@/components/pages/marketing/skeleton/MarketingTableSkeleton';
const RowsOptionsMenu = ({
props,
@@ -214,32 +216,6 @@ const MarketingTable = () => {
updateFilter('customer_id', '');
};
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Product filter
if (tableFilterState.product_ids) {
count += 1;
}
// Status filter
if (tableFilterState.status) {
count += 1;
}
// Customer filter
if (tableFilterState.customer_id) {
count += 1;
}
return count;
}, [
tableFilterState.product_ids,
tableFilterState.status,
tableFilterState.customer_id,
]);
const approveClickHandler = () => {
setApproveAction('APPROVED');
confirmationModal.openModal();
@@ -588,28 +564,14 @@ const MarketingTable = () => {
)}
</div>
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={() => {
filterModal.openModal();
}}
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':
activeFiltersCount > 0,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{activeFiltersCount > 0 && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
@@ -655,28 +617,49 @@ const MarketingTable = () => {
</Dropdown>
</div>
</div>
<Table
rowSelection={rowSelection}
setRowSelection={setRowSelection}
onPageChange={setPage}
onPageSizeChange={setPageSize}
data={allData}
columns={columns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
totalItems={
isResponseSuccess(marketing) ? marketing?.meta?.total_results : 0
}
isLoading={isLoadingMarketing}
className={{
containerClassName: cn('p-3', {
'w-full mb-20':
isResponseSuccess(marketing) && marketing?.data?.length === 0,
}),
bodyColumnClassName:
'last:text-end last:w-17 first:text-start first:w-5',
}}
/>
<div className='flex flex-col mb-4'>
{isLoadingMarketing ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !isResponseSuccess(marketing) || marketing.data?.length === 0 ? (
<div className='p-3'>
<MarketingTableSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table
rowSelection={rowSelection}
setRowSelection={setRowSelection}
onPageChange={setPage}
onPageSizeChange={setPageSize}
data={allData}
columns={columns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
totalItems={
isResponseSuccess(marketing)
? marketing?.meta?.total_results
: 0
}
isLoading={isLoadingMarketing}
className={{
containerClassName: cn('p-3 mb-0'),
bodyColumnClassName:
'last:text-end last:w-17 first:text-start first:w-5',
}}
/>
)}
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
@@ -763,7 +746,7 @@ const MarketingTable = () => {
}
columns={[
{
header: 'Kandang',
header: 'Gudang Fisik',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
@@ -195,7 +195,9 @@ const SalesOrderFormModal = ({
product.marketing_type?.value?.toLowerCase() === 'telur'
? convertionUnitValue === 'PETI'
? 'PETI'
: 'KG' // termasuk "QTY" dan "KG"
: convertionUnitValue === 'QTY'
? 'QTY'
: 'KG'
: undefined;
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
@@ -207,7 +209,6 @@ const SalesOrderFormModal = ({
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(String(product.unit_price || 0)),
total_weight: parseFloat(String(product.total_weight || 0)),
@@ -0,0 +1,14 @@
import { array, mixed, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const MarketingFilterSchema = object({
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
status: mixed<OptionType<string>>().nullable(),
customer: mixed<OptionType<number>>().nullable(),
});
export type MarketingFilterFormValues = {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
};
@@ -13,6 +13,7 @@ import {
Marketing,
} from '@/types/api/marketing/marketing';
import { formatDate, formatTitleCase } from '@/lib/helper';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type MarketingSchemaType = {
customer_id: number | undefined;
@@ -97,17 +98,21 @@ export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
export const SalesProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
const warehouseOption = {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
};
return {
id: product.id,
vehicle_number: product.vehicle_number,
warehouse_id: product.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
kandang: warehouseOption,
product_warehouse: {
value: product.product_warehouse.id,
label: product.product_warehouse.product.name,
label: getProductWarehouseOptionLabel(product.product_warehouse),
},
product_warehouse_data: product.product_warehouse,
product_warehouse_id: product.product_warehouse.id,
@@ -139,11 +144,34 @@ export const DeliveryProductToFieldValues = (
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => {
const soId = salesOrders.find(
const salesOrder = salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
)?.id;
);
const warehouseOption = {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
};
const initialSisaBerat =
item?.total_weight &&
salesOrder?.weight_per_convertion &&
salesOrder?.total_peti
? Number(item.total_weight) -
Number(salesOrder.weight_per_convertion) *
Number(salesOrder.total_peti)
: 0;
const initialPricePerConvertion =
item?.total_price &&
salesOrder?.total_peti &&
Number(salesOrder.total_peti) !== 0
? (Number(item.total_price) -
initialSisaBerat * Number(item.unit_price || 0)) /
Number(salesOrder.total_peti)
: Number(item?.unit_price || 0);
return {
id: soId,
id: salesOrder?.id,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
@@ -152,19 +180,31 @@ 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: soId,
marketing_product_id: salesOrder?.id,
marketing_type: salesOrder?.marketing_type
? {
value: salesOrder?.marketing_type,
label: formatTitleCase(salesOrder?.marketing_type),
}
: null,
convertion_unit: salesOrder?.convertion_unit
? {
value: salesOrder?.convertion_unit.toLowerCase(),
label: formatTitleCase(salesOrder?.convertion_unit),
}
: null,
marketing_product: {
id: soId,
id: salesOrder?.id,
vehicle_number: item.vehicle_number,
warehouse_id: item.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: item.product_warehouse.warehouse.id,
kandang: {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
},
kandang: warehouseOption,
product_warehouse: {
value: item.product_warehouse.id,
label: item.product_warehouse.product.name,
label: getProductWarehouseOptionLabel(item.product_warehouse),
},
product_warehouse_data: item.product_warehouse,
product_warehouse_id: item.product_warehouse.id,
unit_price: item.unit_price,
total_weight: item.total_weight,
@@ -172,8 +212,13 @@ export const DeliveryProductToFieldValues = (
avg_weight: item.avg_weight,
total_price: item.total_price,
},
total_peti: salesOrder?.total_peti,
weight_per_convertion:
item?.weight_per_convertion ?? salesOrder?.weight_per_convertion ?? 0,
price_per_convertion: initialPricePerConvertion,
} as DeliveryOrderProductFormValues;
});
return data;
};
export const mergeSOwithDO = (
@@ -181,10 +226,25 @@ export const mergeSOwithDO = (
deliveryOrders: DeliveryOrderProductFormValues[],
autofill?: boolean
): DeliveryOrderProductFormValues[] => {
const hasDeliveryOrders = deliveryOrders.length > 0;
return salesOrders.map((so) => {
const delivery = deliveryOrders.find(
(d) => d?.marketing_product_id === so.id
);
const isTelurQty =
so.marketing_type?.value?.toLowerCase() === 'telur' &&
so.convertion_unit?.value?.toLowerCase() === 'qty';
const salesOrderUnitPrice =
isTelurQty && Number(so.total_price || 0) > 0 && Number(so.qty || 0) > 0
? Number(so.total_price) / Number(so.qty)
: so.unit_price;
const salesOrderPricePerQty =
isTelurQty &&
Number(so.total_price || 0) > 0 &&
Number(so.total_weight || 0) > 0
? Number(so.total_price) / Number(so.total_weight)
: so.price_per_qty;
return {
...so, // nilai dasar dari sales order
@@ -192,30 +252,50 @@ export const mergeSOwithDO = (
delivery_date: delivery?.delivery_date || undefined,
do_number: delivery?.do_number || undefined,
vehicle_number: delivery?.vehicle_number || so.vehicle_number,
unit_price: autofill ? so.unit_price : delivery?.unit_price,
total_weight: autofill ? so.total_weight : delivery?.total_weight,
qty: autofill ? so.qty : delivery?.qty,
avg_weight: autofill ? so.avg_weight : delivery?.avg_weight,
total_price: autofill ? so.total_price : delivery?.total_price,
unit_price:
autofill && hasDeliveryOrders
? delivery?.unit_price
: salesOrderUnitPrice,
total_weight:
autofill && hasDeliveryOrders
? delivery?.total_weight
: so.total_weight,
qty: autofill && hasDeliveryOrders ? delivery?.qty : so.qty,
avg_weight:
autofill && hasDeliveryOrders ? delivery?.avg_weight : so.avg_weight,
total_price:
autofill && hasDeliveryOrders ? delivery?.total_price : so.total_price,
marketing_product: so, // jika ada, override
uom: autofill ? so.uom : delivery?.uom,
weight_per_convertion: autofill
? so.weight_per_convertion
: delivery?.weight_per_convertion,
price_per_convertion: autofill
? so.price_per_convertion
: delivery?.price_per_convertion,
convertion_unit: autofill
? so.convertion_unit
: delivery?.convertion_unit,
marketing_type: autofill ? so.marketing_type : delivery?.marketing_type,
total_peti: autofill ? so.total_peti : delivery?.total_peti,
price_per_qty: autofill ? so.price_per_qty : delivery?.price_per_qty,
sisa_berat: autofill ? so.sisa_berat : delivery?.sisa_berat,
price_sisa_berat: autofill
? so.price_sisa_berat
: delivery?.price_sisa_berat,
week: autofill ? so.week : delivery?.week,
uom: autofill && hasDeliveryOrders ? delivery?.uom : so.uom,
weight_per_convertion:
autofill && hasDeliveryOrders
? delivery?.weight_per_convertion
: so.weight_per_convertion,
price_per_convertion:
autofill && hasDeliveryOrders
? delivery?.price_per_convertion
: so.price_per_convertion,
convertion_unit:
autofill && hasDeliveryOrders
? delivery?.convertion_unit
: so.convertion_unit,
marketing_type:
autofill && hasDeliveryOrders
? delivery?.marketing_type
: so.marketing_type,
total_peti:
autofill && hasDeliveryOrders ? delivery?.total_peti : so.total_peti,
price_per_qty:
autofill && hasDeliveryOrders
? delivery?.price_per_qty
: salesOrderPricePerQty,
sisa_berat:
autofill && hasDeliveryOrders ? delivery?.sisa_berat : so.sisa_berat,
price_sisa_berat:
autofill && hasDeliveryOrders
? delivery?.price_sisa_berat
: so.price_sisa_berat,
week: autofill && hasDeliveryOrders ? delivery?.week : so.week,
} as DeliveryOrderProductFormValues;
});
};
@@ -10,7 +10,10 @@ import NumberInput from '@/components/input/NumberInput';
import PatternInput from '@/components/input/PatternInput';
import { formatTitleCase, formatVechicleNumber } from '@/lib/helper';
import DateInput from '@/components/input/DateInput';
import { BaseSalesOrder } from '@/types/api/marketing/marketing';
import {
BaseSalesOrder,
BaseDeliveryOrder,
} from '@/types/api/marketing/marketing';
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -29,9 +32,67 @@ import Dropdown from '@/components/Dropdown';
import { Icon } from '@iconify/react';
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
type PricingOption =
| string
| {
value: string;
label: string;
}
| null
| undefined;
type PricingSource =
| {
marketing_type?: PricingOption;
convertion_unit?: PricingOption;
total_price?: string | number | null;
qty?: string | number | null;
total_weight?: string | number | null;
unit_price?: string | number | null;
price_per_qty?: number | null;
}
| null
| undefined;
const getOptionValue = (value?: PricingOption) => {
if (!value) return undefined;
if (typeof value === 'string') return value.toLowerCase();
return value.value?.toLowerCase();
};
const isTelurQtyProduct = (value?: PricingSource) =>
getOptionValue(value?.marketing_type) === 'telur' &&
getOptionValue(value?.convertion_unit) === 'qty';
const getDisplayedUnitPrice = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.qty || 0) > 0
) {
return Number(value?.total_price) / Number(value?.qty);
}
return value?.unit_price ?? undefined;
};
const getDisplayedPricePerQty = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.total_weight || 0) > 0
) {
return Number(value?.total_price) / Number(value?.total_weight);
}
return value?.price_per_qty ?? null;
};
const DeliveryOrderProductForm = ({
formState,
salesOrders,
deliveryOrders,
initialValues,
exisitingValues,
onUpdateForm,
@@ -39,6 +100,7 @@ const DeliveryOrderProductForm = ({
}: {
formState: 'add' | 'edit';
salesOrders: BaseSalesOrder[];
deliveryOrders?: BaseDeliveryOrder[];
initialValues?: DeliveryOrderProductFormValues;
exisitingValues?: DeliveryOrderProductFormValues[];
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
@@ -71,7 +133,7 @@ const DeliveryOrderProductForm = ({
? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti)
: 0;
: Number(initialValues?.unit_price || 0);
const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti
@@ -107,7 +169,7 @@ const DeliveryOrderProductForm = ({
if (!Boolean(item.qty)) {
return {
value: item.id,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.warehouse?.label ?? item.marketing_product?.kandang?.label}`,
} as OptionType;
} else {
return null;
@@ -115,22 +177,82 @@ const DeliveryOrderProductForm = ({
})
?.filter((item) => item != null) as OptionType[];
const hasDeliveryOrder = useMemo(() => {
return (
deliveryOrders &&
deliveryOrders.length > 0 &&
deliveryOrders.some(
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
)
);
}, [deliveryOrders]);
const deliveryOrder = useMemo(() => {
if (!hasDeliveryOrder || !deliveryOrders) return null;
for (const doItem of deliveryOrders) {
const found = doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
if (found) {
return {
...found,
delivery_date: doItem.delivery_date,
do_number: doItem.do_number,
};
}
}
return null;
}, [deliveryOrders, hasDeliveryOrder, initialValues]);
const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id
);
const defaultPricingSource: PricingSource = {
marketing_type:
initialValues?.marketing_type ?? salesOrder?.marketing_type ?? null,
convertion_unit:
initialValues?.convertion_unit ?? salesOrder?.convertion_unit ?? null,
total_price:
deliveryOrder?.total_price ??
initialValues?.total_price ??
salesOrder?.total_price,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? salesOrder?.qty,
total_weight:
deliveryOrder?.total_weight ??
initialValues?.total_weight ??
salesOrder?.total_weight,
unit_price:
deliveryOrder?.unit_price ??
initialValues?.unit_price ??
salesOrder?.unit_price,
price_per_qty: initialValues?.price_per_qty ?? null,
};
const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
delivery_date: initialValues?.delivery_date || undefined,
vehicle_number: initialValues?.vehicle_number || undefined,
delivery_date:
deliveryOrder?.delivery_date ||
initialValues?.delivery_date ||
undefined,
vehicle_number:
deliveryOrder?.vehicle_number ||
initialValues?.vehicle_number ||
undefined,
marketing_product_id:
salesOrder?.id || initialValues?.marketing_product_id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
unit_price: getDisplayedUnitPrice(defaultPricingSource),
total_weight:
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
avg_weight:
deliveryOrder?.avg_weight ?? initialValues?.avg_weight ?? undefined,
total_price:
deliveryOrder?.total_price ?? initialValues?.total_price ?? undefined,
marketing_product: initialValues?.marketing_product || undefined,
uom: initialValues?.uom || '',
weight_per_convertion:
@@ -141,7 +263,7 @@ const DeliveryOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null,
price_per_qty: initialValues?.price_per_qty ?? null,
price_per_qty: getDisplayedPricePerQty(defaultPricingSource),
sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null,
@@ -236,6 +358,25 @@ const DeliveryOrderProductForm = ({
});
};
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
const handleFieldChange = (
field: string,
value: number | string,
callback?: () => void
) => {
formik.setFieldValue(field, value);
setTimeout(() => {
handleMarketingCalculation(field, {
values: { ...formik.values, [field]: value },
setFieldValue: formik.setFieldValue,
hasSisaBerat,
});
}, 0);
if (callback) callback();
};
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
setHasSisaBerat(newHasSisaBerat);
@@ -265,11 +406,15 @@ const DeliveryOrderProductForm = ({
if (!Boolean(initialValues.qty)) {
handleResetForm();
} else {
setFormikValues(initialValues);
setFormikValues({
...initialValues,
unit_price: getDisplayedUnitPrice(initialValues),
price_per_qty: getDisplayedPricePerQty(initialValues),
});
if (initialValues?.marketing_product_id) {
setSelectedProduct({
value: initialValues?.id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
} as OptionType);
}
}
@@ -394,10 +539,11 @@ const DeliveryOrderProductForm = ({
marketing_product_id: selected.value as number,
marketing_product: soFieldValues,
qty: so.qty,
unit_price: so.unit_price,
unit_price: getDisplayedUnitPrice(so),
total_price: so.total_price,
avg_weight: so.avg_weight,
total_weight: so.total_weight,
price_per_qty: getDisplayedPricePerQty(so),
vehicle_number: so.vehicle_number,
week: soFieldValues.week ?? null,
});
@@ -408,7 +554,11 @@ const DeliveryOrderProductForm = ({
text={
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.kandang?.label ?? ''
)?.marketing_product?.warehouse?.label ??
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.kandang?.label ??
''
}
color='success'
className={{
@@ -520,13 +670,11 @@ const DeliveryOrderProductForm = ({
} per ${formik.values.convertion_unit?.value}`}
value={formik.values.weight_per_convertion ?? ''}
onChange={(e) => {
formik.setFieldValue(
'weight_per_convertion',
Number(e.target.value)
const value = Number(e.target.value);
handleFieldChange('weight_per_convertion', value, () =>
setCurrentInput(e.target.name)
);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('weight_per_convertion')}
/>
</div>
</div>
@@ -564,10 +712,11 @@ const DeliveryOrderProductForm = ({
name='total_peti'
value={formik.values.total_peti ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('total_peti', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('total_peti')}
isError={
formik.touched.total_peti && Boolean(formik.errors.total_peti)
}
@@ -575,7 +724,7 @@ const DeliveryOrderProductForm = ({
placeholder='Masukan Total Peti'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Kg</span>
<span className='text-sm text-base-content/50'>Peti</span>
</div>
}
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -592,10 +741,11 @@ const DeliveryOrderProductForm = ({
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('avg_weight', value, () =>
setCurrentInput('avg_weight')
);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight &&
Boolean(formik.errors.avg_weight)
@@ -613,16 +763,20 @@ const DeliveryOrderProductForm = ({
name='total_weight'
value={formik.values.total_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('total_weight', value, () =>
setCurrentInput('total_weight')
);
}}
onBlur={() => handleBlurField('total_weight')}
isError={
formik.touched.total_weight &&
Boolean(formik.errors.total_weight)
}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/>
)}
@@ -638,10 +792,11 @@ const DeliveryOrderProductForm = ({
name='qty'
value={formik.values.qty}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('qty', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('qty')}
isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
@@ -677,10 +832,11 @@ const DeliveryOrderProductForm = ({
name='price_per_convertion'
value={formik.values.price_per_convertion ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('price_per_convertion', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('price_per_convertion')}
isError={
formik.touched.price_per_convertion &&
Boolean(formik.errors.price_per_convertion)
@@ -690,47 +846,49 @@ const DeliveryOrderProductForm = ({
/>
)}
{/* Harga per butir untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Butir (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
formik.setFieldValue('price_per_qty', Number(e.target.value));
setCurrentInput('price_per_qty');
}}
onBlur={() => handleBlurField('price_per_qty')}
isError={
formik.touched.price_per_qty &&
Boolean(formik.errors.price_per_qty)
}
errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Butir'
/>
)}
{/* Harga Satuan */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${isResponseSuccess(productData) ? productData?.data?.uom?.name : 'Produk'} (Rp)`}
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('unit_price')}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Kg (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('price_per_qty', value, () =>
setCurrentInput('price_per_qty')
);
}}
isError={
formik.touched.price_per_qty &&
Boolean(formik.errors.price_per_qty)
}
errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Kg'
/>
)}
{/* Sisa kg diluar peti */}
{formik.values.convertion_unit?.value.toLowerCase() === 'peti' && (
<div className='flex flex-col'>
@@ -760,10 +918,11 @@ const DeliveryOrderProductForm = ({
name='sisa_berat'
value={formik.values.sisa_berat ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('sisa_berat', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('sisa_berat')}
isError={
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
}
@@ -776,10 +935,11 @@ const DeliveryOrderProductForm = ({
name='price_sisa_berat'
value={formik.values.price_sisa_berat ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('price_sisa_berat', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('price_sisa_berat')}
isError={
formik.touched.price_sisa_berat &&
Boolean(formik.errors.price_sisa_berat)
@@ -797,10 +957,11 @@ const DeliveryOrderProductForm = ({
name='total_price'
value={formik.values.total_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('total_price', value, () =>
setCurrentInput('total_price')
);
}}
onBlur={() => handleBlurField('total_price')}
isError={
formik.touched.total_price && Boolean(formik.errors.total_price)
}
@@ -3,6 +3,11 @@ import * as Yup from 'yup';
type SalesOrderProductSchemaType = {
id?: number | undefined;
warehouse_id?: number;
warehouse?: {
value: number;
label: string;
} | null;
kandang_id?: number;
kandang?: {
value: number;
@@ -44,15 +49,22 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
Yup.object({
id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({
warehouse: Yup.object({
value: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
label: Yup.string().required('Kandang wajib diisi!'),
.min(1, 'Gudang fisik wajib diisi!')
.required('Gudang fisik wajib diisi!'),
label: Yup.string().required('Gudang fisik wajib diisi!'),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
warehouse_id: Yup.number()
.min(1, 'Gudang fisik wajib diisi!')
.required('Gudang fisik wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
kandang_id: Yup.number().optional(),
product_warehouse: Yup.object({
value: Yup.number()
.min(1, 'Produk wajib diisi!')
@@ -7,7 +7,7 @@ import {
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useEffect, useMemo, useState } from 'react';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory';
@@ -31,6 +31,7 @@ import {
import { Icon } from '@iconify/react';
import Dropdown from '@/components/Dropdown';
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
const SalesOrderProductForm = ({
initialValues,
@@ -67,7 +68,25 @@ const SalesOrderProductForm = ({
? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti)
: 0;
: Number(initialValues?.unit_price || 0);
const isInitialTelurQty =
initialValues?.marketing_type?.value?.toLowerCase() === 'telur' &&
initialValues?.convertion_unit?.value?.toLowerCase() === 'qty';
const initialUnitPrice =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.qty || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.qty)
: initialValues?.unit_price || '';
const initialPricePerQty =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.total_weight || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.total_weight)
: (initialValues?.price_per_qty ?? null);
const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti
@@ -84,11 +103,15 @@ const SalesOrderProductForm = ({
enableReinitialize: true,
initialValues: {
vehicle_number: initialValues?.vehicle_number || '',
warehouse_id:
initialValues?.warehouse_id ?? initialValues?.kandang_id ?? undefined,
warehouse: initialValues?.warehouse ?? initialValues?.kandang ?? null,
kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || null,
product_warehouse: initialValues?.product_warehouse || null,
product_warehouse_data: initialValues?.product_warehouse_data || null,
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialValues?.unit_price || '',
unit_price: initialUnitPrice,
total_weight: initialValues?.total_weight || '',
qty: initialValues?.qty || '',
avg_weight: initialValues?.avg_weight || '',
@@ -102,7 +125,7 @@ const SalesOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null,
price_per_qty: initialValues?.price_per_qty ?? null,
price_per_qty: initialPricePerQty,
sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null,
@@ -132,11 +155,11 @@ const SalesOrderProductForm = ({
// ===== Options =====
const {
options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
setInputValue: setWarehouseSearchValue,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
// Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => {
@@ -147,7 +170,6 @@ const SalesOrderProductForm = ({
// }, []);
const {
options: warehouseSourceOptions,
rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue,
@@ -156,32 +178,69 @@ const SalesOrderProductForm = ({
ProductWarehouseApi.basePath,
'id',
'product.name',
'',
'search',
{
warehouse_id: formik.values.kandang_id?.toString() ?? '',
limit: '100',
available_only: 'true',
warehouse_id: formik.values.warehouse_id?.toString() ?? '',
type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '',
}
);
const productOptionsFiltered = useMemo(() => {
return warehouseSourceOptions.filter(
(product) =>
!exisitingValues
?.map((item) => item.product_warehouse_id)
.includes(product.value)
if (!isResponseSuccess(warehouseSourceRawData)) {
return initialValues?.product_warehouse
? [initialValues.product_warehouse]
: [];
}
const selectedProductIds = new Set(
exisitingValues
?.filter((item) => item.id !== initialValues?.id)
.map((item) => Number(item.product_warehouse_id))
.filter((item) => item > 0) ?? []
);
}, [warehouseSourceOptions, exisitingValues]);
const options = warehouseSourceRawData.data
.filter((item: ProductWarehouse) => !selectedProductIds.has(item.id))
.map((item: ProductWarehouse) => ({
value: item.id,
label: getProductWarehouseOptionLabel(item),
}));
if (
initialValues?.product_warehouse &&
initialValues?.product_warehouse_id
) {
const exists = options.find(
(option) =>
Number(option.value) === Number(initialValues.product_warehouse_id)
);
if (!exists) {
options.push(initialValues.product_warehouse);
}
}
return options;
}, [warehouseSourceRawData, exisitingValues, initialValues]);
// ===== Handler =====
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
const warehouse = (val as OptionType | null) ?? null;
formik.setFieldValue('warehouse', warehouse);
formik.setFieldValue('warehouse_id', warehouse?.value);
formik.setFieldValue('kandang', warehouse);
formik.setFieldValue('kandang_id', warehouse?.value);
formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', '');
};
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
const productWarehouseChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('product_warehouse', val as OptionType);
const newId = (val as OptionType)?.value;
formik.setFieldValue('product_warehouse_id', newId);
@@ -191,6 +250,7 @@ const SalesOrderProductForm = ({
(item: ProductWarehouse) => item.id === newId
);
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity);
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if (
@@ -204,6 +264,8 @@ const SalesOrderProductForm = ({
}
handleBlurField('qty');
} else {
setSelectedProductWarehouse(null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', '');
formik.setFieldValue('uom', '');
formik.setFieldValue('week', null);
@@ -217,9 +279,12 @@ const SalesOrderProductForm = ({
formik.resetForm({
values: {
vehicle_number: '',
warehouse_id: undefined,
warehouse: null,
kandang_id: undefined,
kandang: null,
product_warehouse: null,
product_warehouse_data: null,
product_warehouse_id: undefined,
unit_price: '',
total_weight: '',
@@ -250,6 +315,25 @@ const SalesOrderProductForm = ({
});
};
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
const handleFieldChange = (
field: string,
value: number | string,
callback?: () => void
) => {
formik.setFieldValue(field, value);
setTimeout(() => {
handleMarketingCalculation(field, {
values: { ...formik.values, [field]: value },
setFieldValue: formik.setFieldValue,
hasSisaBerat,
});
}, 0);
if (callback) callback();
};
// Handler khusus untuk toggle sisa berat - langsung pakai nilai baru
const handleSisaBeratToggle = (newHasSisaBerat: boolean) => {
setHasSisaBerat(newHasSisaBerat);
@@ -291,6 +375,10 @@ const SalesOrderProductForm = ({
handleBlurField('week');
}, [formik.values.week]);
useEffect(() => {
setSelectedProductWarehouse(initialValues?.product_warehouse_data || null);
}, [initialValues?.product_warehouse_data]);
return (
<>
<form
@@ -329,22 +417,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.vehicle_number}
/>
{/* Gudang */}
{/* Gudang Fisik */}
<SelectInputRadio
required
label='Gudang'
options={kandangSourceOptions}
isLoading={isLoadingKandangSourceOptions}
value={formik.values.kandang}
onChange={kandangChangeHandler}
label='Gudang Fisik'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={formik.values.warehouse}
onChange={warehouseChangeHandler}
isClearable
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
onInputChange={setWarehouseSearchValue}
onMenuScrollToBottom={loadMoreWarehouses}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
formik.touched.warehouse_id && Boolean(formik.errors.warehouse_id)
}
errorMessage={formik.errors.kandang_id}
placeholder='Pilih Gudang'
errorMessage={formik.errors.warehouse_id}
placeholder='Pilih Gudang Fisik'
/>
{/* Kategori */}
@@ -355,8 +443,9 @@ const SalesOrderProductForm = ({
value={formik.values.marketing_type}
onChange={(val) => {
formik.setFieldValue('marketing_type', val);
warehouseChangeHandler(null);
productWarehouseChangeHandler(null);
formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('convertion_unit', null);
formik.setFieldValue('weight_per_convertion', null);
@@ -373,18 +462,18 @@ const SalesOrderProductForm = ({
options={productOptionsFiltered}
isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse}
onChange={warehouseChangeHandler}
onChange={productWarehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
isClearable
placeholder={
formik.values.kandang_id
formik.values.warehouse_id
? productOptionsFiltered.length == 0
? 'Tidak ada produk yang tersedia'
: 'Pilih produk'
: 'Pilih Kandang Terlebih Dahulu'
: 'Pilih Gudang Fisik Terlebih Dahulu'
}
isDisabled={!formik.values.kandang_id}
isDisabled={!formik.values.warehouse_id}
isError={
formik.touched.product_warehouse_id &&
Boolean(formik.errors.product_warehouse_id)
@@ -452,7 +541,7 @@ const SalesOrderProductForm = ({
<input
type='radio'
checked={
formik.values.convertion_unit?.value ===
formik.values.convertion_unit?.value.toLowerCase() ===
option.value
}
onChange={() => null}
@@ -475,13 +564,13 @@ const SalesOrderProductForm = ({
} per ${formik.values.convertion_unit?.value}`}
value={formik.values.weight_per_convertion ?? ''}
onChange={(e) => {
formik.setFieldValue(
'weight_per_convertion',
Number(e.target.value)
const value = Number(e.target.value)
? Number(e.target.value)
: '';
handleFieldChange('weight_per_convertion', value, () =>
setCurrentInput(e.target.name)
);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('weight_per_convertion')}
/>
</div>
</div>
@@ -519,10 +608,11 @@ const SalesOrderProductForm = ({
name='total_peti'
value={formik.values.total_peti ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('total_peti', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('total_peti')}
isError={
formik.touched.total_peti && Boolean(formik.errors.total_peti)
}
@@ -530,7 +620,7 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Peti'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Kg</span>
<span className='text-sm text-base-content/50'>Peti</span>
</div>
}
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -547,10 +637,11 @@ const SalesOrderProductForm = ({
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('avg_weight', value, () =>
setCurrentInput('avg_weight')
);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight &&
Boolean(formik.errors.avg_weight)
@@ -568,16 +659,20 @@ const SalesOrderProductForm = ({
name='total_weight'
value={formik.values.total_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('total_weight', value, () =>
setCurrentInput('total_weight')
);
}}
onBlur={() => handleBlurField('total_weight')}
isError={
formik.touched.total_weight &&
Boolean(formik.errors.total_weight)
}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/>
)}
@@ -593,10 +688,11 @@ const SalesOrderProductForm = ({
name='qty'
value={formik.values.qty}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('qty', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('qty')}
isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
@@ -630,10 +726,11 @@ const SalesOrderProductForm = ({
name='price_per_convertion'
value={formik.values.price_per_convertion ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('price_per_convertion', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('price_per_convertion')}
isError={
formik.touched.price_per_convertion &&
Boolean(formik.errors.price_per_convertion)
@@ -643,46 +740,48 @@ const SalesOrderProductForm = ({
/>
)}
{/* Harga per butir untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Butir (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
formik.setFieldValue('price_per_qty', Number(e.target.value));
setCurrentInput('price_per_qty');
}}
onBlur={() => handleBlurField('price_per_qty')}
isError={
formik.touched.price_per_qty &&
Boolean(formik.errors.price_per_qty)
}
errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Butir'
/>
)}
{/* Harga Satuan per Uom Produk Warehouse */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label !== 'qty' ? 'Kg' : (selectedProductWarehouse?.product?.uom?.name ?? 'Produk')} (Rp)`}
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('unit_price')}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
placeholder='Masukan Harga Satuan...'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Kg (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('price_per_qty', value, () =>
setCurrentInput('price_per_qty')
);
}}
isError={
formik.touched.price_per_qty &&
Boolean(formik.errors.price_per_qty)
}
errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Kg'
/>
)}
@@ -715,10 +814,11 @@ const SalesOrderProductForm = ({
name='sisa_berat'
value={formik.values.sisa_berat ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('sisa_berat', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('sisa_berat')}
isError={
formik.touched.sisa_berat && Boolean(formik.errors.sisa_berat)
}
@@ -731,10 +831,11 @@ const SalesOrderProductForm = ({
name='price_sisa_berat'
value={formik.values.price_sisa_berat ?? undefined}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('price_sisa_berat', value, () =>
setCurrentInput(e.target.name)
);
}}
onBlur={() => handleBlurField('price_sisa_berat')}
isError={
formik.touched.price_sisa_berat &&
Boolean(formik.errors.price_sisa_berat)
@@ -752,10 +853,11 @@ const SalesOrderProductForm = ({
name='total_price'
value={formik.values.total_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
const value = Number(e.target.value);
handleFieldChange('total_price', value, () =>
setCurrentInput('total_price')
);
}}
onBlur={() => handleBlurField('total_price')}
isError={
formik.touched.total_price && Boolean(formik.errors.total_price)
}
@@ -2,10 +2,12 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
import Button from '@/components/Button';
import Card from '@/components/Card';
import { Icon } from '@iconify/react';
import { useRef } from 'react';
import { useRef, useMemo } from 'react';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import { Marketing } from '@/types/api/marketing/marketing';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { DeliveryProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[];
@@ -42,7 +44,34 @@ const DeliveryOrderProductTable = ({
const approvalStepNumber = marketing?.latest_approval?.step_number;
const renderTableContent = (item: DeliveryOrderProductFormValues) => {
const hasDeliveryOrder = useMemo(() => {
return (
marketing?.delivery_order &&
marketing.delivery_order.length > 0 &&
marketing.delivery_order.some(
(doItem) => doItem.deliveries && doItem.deliveries.length > 0
)
);
}, [marketing?.delivery_order]);
const deliveryItems = useMemo(() => {
if (!hasDeliveryOrder) return [];
return (
marketing?.delivery_order?.flatMap((doItem) =>
DeliveryProductToFieldValues(marketing?.sales_order, doItem).map(
(delivery) => ({
...delivery,
do_number: doItem.do_number,
delivery_date: doItem.delivery_date,
warehouse: doItem.warehouse,
})
)
) ?? []
);
}, [marketing?.delivery_order, hasDeliveryOrder]);
const renderSalesOrderContent = (item: DeliveryOrderProductFormValues) => {
const doItem = marketing?.delivery_order?.find(
(doItem) => doItem.do_number === item.do_number
);
@@ -56,7 +85,7 @@ const DeliveryOrderProductTable = ({
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
{formType !== 'success' &&
{/* {formType !== 'success' &&
(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
@@ -73,15 +102,16 @@ const DeliveryOrderProductTable = ({
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
</div>
)}
)} */}
</div>
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>
{doItem?.warehouse?.name ||
item.marketing_product?.warehouse?.label ||
item.marketing_product?.product_warehouse_data?.warehouse?.name}
</td>
</tr>
@@ -111,12 +141,15 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'>
{formatNumber(Number(item.total_weight))}
{formatNumber(Number(item.total_weight))} Kg
</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'>
Total Harga Satuan
{item.convertion_unit?.label.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
@@ -185,50 +218,240 @@ const DeliveryOrderProductTable = ({
);
};
const renderDeliveryOrderContent = (
item: DeliveryOrderProductFormValues & {
do_number: string;
delivery_date: string;
warehouse: Warehouse;
}
) => {
const parentDoItem = marketing?.delivery_order?.find(
(doItem) => doItem.do_number === item.do_number
);
return (
<>
<tr className='border-b border-tools-table-outline border-base-content/5'>
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
Label
</th>
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
{formType !== 'success' &&
(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number, item);
}}
className='p-0 hover:text-base-content'
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
</div>
)}
</div>
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>{item.warehouse?.name}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'>
{item.marketing_product?.product_warehouse_data?.product.name}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
: '-'}
</td>
</tr>
{Number(item.avg_weight ?? 0) > 0 && (
<tr>
<td className='text-sm px-4 py-3'>Avg Bobot</td>
<td className='text-sm px-4 py-3'>
{formatNumber(Number(item.avg_weight))} Kg
</td>
</tr>
)}
{Number(item.total_weight ?? 0) > 0 && (
<tr>
<td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'>
{formatNumber(Number(item.total_weight))}
</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(Number(item.unit_price))}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(Number(item.total_price))}
</td>
</tr>
</>
<tr className='border-b border-t border-tools-table-outline border-base-content/5'>
<th className='w-1/3 text-start not-first:font-medium text-base-content/50 text-sm px-4 py-3'>
Label
</th>
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
</div>
</th>
</tr>
<>
{approvalStepNumber !== 1 && (
<tr>
<td className='text-sm px-4 py-3'>Tanggal Pengiriman</td>
<td className='text-sm px-4 py-3'>
{item.delivery_date
? formatDate(item.delivery_date, 'DD MMM YYYY')
: '-'}
</td>
</tr>
)}
{item.do_number && (
<tr>
<td className='text-sm px-4 py-3'>No. Pengiriman</td>
<td className='text-sm px-4 py-3'>{item.do_number}</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>No. Polisi</td>
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr>
{parentDoItem && (
<tr>
<td className='text-sm px-4 py-3'>Dokumen Pengiriman</td>
<td className='text-sm px-4 py-3'>
<DeliveryOrderExport
data={marketing}
deliveryOrder={parentDoItem}
/>
</td>
</tr>
)}
</>
</>
);
};
return (
<>
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
{data.map((item) => (
<div key={`table-${item.id}`}>
{formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>{renderTableContent(item)}</tbody>
</table>
</div>
) : (
<Card
key={`table-${item.id}`}
title={
item.marketing_product?.product_warehouse?.label || 'Produk'
}
collapsible={true}
defaultCollapsed={false}
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
title: 'px-2 py-1.5 font-normal text-sm',
collapsible: 'rounded-lg',
}}
{hasDeliveryOrder
? deliveryItems.map((item, index) => (
<div
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>{renderTableContent(item)}</tbody>
</table>
</Card>
)}
</div>
))}
{formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>
{renderDeliveryOrderContent(item)}
</tbody>
</table>
</div>
) : (
<Card
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
title={
item.marketing_product?.product_warehouse_data?.product
.name || 'Produk'
}
collapsible={true}
defaultCollapsed={false}
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
title: 'px-2 py-1.5 font-normal text-sm',
collapsible: 'rounded-lg',
}}
>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>
{renderDeliveryOrderContent(item)}
</tbody>
</table>
</Card>
)}
</div>
))
: data.map((item) => (
<div key={`table-${item.id}`}>
{formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>
{renderSalesOrderContent(item)}
</tbody>
</table>
</div>
) : (
<Card
key={`table-${item.id}`}
title={
item.marketing_product?.product_warehouse?.label ||
'Produk'
}
collapsible={true}
defaultCollapsed={false}
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
title: 'px-2 py-1.5 font-normal text-sm',
collapsible: 'rounded-lg',
}}
>
<table
style={{
borderRadius: '0.5rem',
}}
className='border-none w-full'
>
<tbody className='w-full'>
{renderSalesOrderContent(item)}
</tbody>
</table>
</Card>
)}
</div>
))}
</div>
</>
);
@@ -73,8 +73,10 @@ const SalesOrderProductTable = ({
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>{item.kandang?.label}</td>
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>
{item.warehouse?.label ?? item.kandang?.label}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Kategori</td>
@@ -135,8 +137,22 @@ const SalesOrderProductTable = ({
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
</td>
</tr>
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
<tr>
<td className='text-sm px-4 py-3'>Harga Satuan Per Peti</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(
parseFloat(item.unit_price as string) *
parseFloat(String(item.weight_per_convertion))
)}
</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>Harga Satuan</td>
<td className='text-sm px-4 py-3'>
Harga Satuan
{item.convertion_unit?.value.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
@@ -1,7 +1,7 @@
import Button from '@/components/Button';
import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { Document, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
@@ -101,8 +101,8 @@ const PDFDocument = ({
<View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
</Text>
<View style={pdfStyles.divider} />
</View>
@@ -87,8 +87,8 @@ const PDFDocument = ({ data }: { data: Marketing }) => {
<View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
</Text>
<View style={pdfStyles.divider} />
</View>
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Marketing } from '@/types/api/marketing/marketing';
import { ColumnDef } from '@tanstack/react-table';
const MarketingTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no marketing data displayed. Enter marketing data to get started.',
}: {
columns: ColumnDef<Marketing>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default MarketingTableSkeleton;
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -11,75 +11,101 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import RequirePermission from '@/components/helper/RequirePermission';
import AreaTableSkeleton from '@/components/pages/master-data/area/skeleton/AreaTableSkeleton';
import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Area, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `area#${props.row.original.id}`;
const popoverAnchorName = `--anchor-area#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.area.detail'>
<Button
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<RequirePermission permissions='lti.master.area.update'>
<Button
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.area.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.master.area.detail'>
<Button
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.area.update'>
<Button
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:pencil-square' width={20} height={20} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.area.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const AreasTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -87,10 +113,17 @@ const AreasTable = () => {
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
initial: {
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
const {
data: areas,
isLoading,
@@ -101,65 +134,21 @@ const AreasTable = () => {
);
const deleteModal = useModal();
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const areasColumns: ColumnDef<Area>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
useEffect(() => {
setTableState('areas-table', pathname);
}, [pathname, setTableState]);
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedArea(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -179,95 +168,132 @@ const AreasTable = () => {
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const areasColumns: ColumnDef<Area>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props: CellContext<Area, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
setPageSize(newVal.value as number);
};
const deleteClickHandler = () => {
setSelectedArea(props.row.original);
deleteModal.openModal();
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.master.area.create'>
<Button
href='/master-data/area/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Area'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<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.master.area.create'>
<Button
href='/master-data/area/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 Area
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
{/* 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',
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Area>
data={isResponseSuccess(areas) ? areas?.data : []}
columns={areasColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
totalItems={isResponseSuccess(areas) ? areas?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(areas) && areas?.data?.length === 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',
}}
/>
{/* 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(areas) || areas.data?.length === 0 ? (
<div className='p-3'>
<AreaTableSkeleton
columns={areasColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Area>
data={isResponseSuccess(areas) ? areas?.data : []}
columns={areasColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
totalItems={
isResponseSuccess(areas) ? areas?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Area } from '@/types/api/master-data/area';
import { ColumnDef } from '@tanstack/react-table';
const AreaTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no area data displayed. Enter area data to get started.',
}: {
columns: ColumnDef<Area>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default AreaTableSkeleton;
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -11,75 +11,101 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import BankTableSkeleton from '@/components/pages/master-data/bank/skeleton/BankTableSkeleton';
import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Bank, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `bank#${props.row.original.id}`;
const popoverAnchorName = `--anchor-bank#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.banks.detail'>
<Button
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<RequirePermission permissions='lti.master.banks.update'>
<Button
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.banks.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.master.banks.detail'>
<Button
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.banks.update'>
<Button
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:pencil-square' width={20} height={20} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.banks.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const BanksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -87,10 +113,17 @@ const BanksTable = () => {
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
initial: {
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
const {
data: banks,
isLoading,
@@ -101,78 +134,21 @@ const BanksTable = () => {
);
const deleteModal = useModal();
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const banksColumns: ColumnDef<Bank>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'account_number',
header: 'No. Rekening',
},
{
accessorKey: 'owner',
header: 'Pemilik',
cell: (props) => (props.getValue() ? props.getValue() : '-'),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
useEffect(() => {
setTableState('banks-table', pathname);
}, [pathname, setTableState]);
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedBank(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -192,93 +168,145 @@ const BanksTable = () => {
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const banksColumns: ColumnDef<Bank>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'account_number',
header: 'No. Rekening',
},
{
accessorKey: 'owner',
header: 'Pemilik',
cell: (props) => props.getValue() || '-',
},
{
header: 'Aksi',
cell: (props: CellContext<Bank, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
setPageSize(newVal.value as number);
};
const deleteClickHandler = () => {
setSelectedBank(props.row.original);
deleteModal.openModal();
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting]);
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.master.banks.create'>
<Button
href='/master-data/bank/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Bank'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<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.master.banks.create'>
<Button
href='/master-data/bank/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 Bank
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
{/* 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',
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Bank>
data={isResponseSuccess(banks) ? banks?.data : []}
columns={banksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
totalItems={isResponseSuccess(banks) ? banks?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(banks) && banks?.data?.length === 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',
}}
/>
{/* 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(banks) || banks.data?.length === 0 ? (
<div className='p-3'>
<BankTableSkeleton
columns={banksColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Bank>
data={isResponseSuccess(banks) ? banks?.data : []}
columns={banksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
totalItems={
isResponseSuccess(banks) ? banks?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Bank } from '@/types/api/master-data/bank';
import { ColumnDef } from '@tanstack/react-table';
const BankTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no bank data displayed. Enter bank data to get started.',
}: {
columns: ColumnDef<Bank>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default BankTableSkeleton;
@@ -1,81 +1,111 @@
'use client';
import Button from '@/components/Button';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { CustomerApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import CustomerTableSkeleton from '@/components/pages/master-data/customer/skeleton/CustomerTableSkeleton';
import { Customer } from '@/types/api/master-data/customer';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
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 = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Customer, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `customer#${props.row.original.id}`;
const popoverAnchorName = `--anchor-customer#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.customer.detail'>
<Button
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.customer.update'>
<Button
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.customer.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.master.customer.detail'>
<Button
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.customer.update'>
<Button
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:pencil-square' width={20} height={20} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.customer.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const CustomersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -83,16 +113,17 @@ const CustomersTable = () => {
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '', picSort: '' },
initial: {
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
picSort: 'sort_pic',
},
});
// Fetch Data
const [sorting, setSorting] = useState<SortingState>([]);
const {
data: customers,
isLoading,
@@ -102,87 +133,25 @@ const CustomersTable = () => {
CustomerApi.getAllFetcher
);
// State
const deleteModal = useModal();
const [selectedCustomer, setSelectedCustomer] = useState<
Customer | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// Columns Definition
const customersColumns: ColumnDef<Customer>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'pic',
header: 'PIC',
cell: (props) => props.row.original.pic.name,
},
{
accessorKey: 'type',
header: 'Type',
cell: (props) => props.row.original.type,
},
{
accessorKey: 'phone',
header: 'Phone',
},
{
accessorKey: 'email',
header: 'Email',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
useEffect(() => {
setTableState('customers-table', pathname);
}, [pathname, setTableState]);
const deleteClickHandler = () => {
setSelectedCustomer(props.row.original);
deleteModal.openModal();
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
// Handler
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -202,81 +171,147 @@ const CustomersTable = () => {
toast.success('Successfully delete Customer!');
setIsDeleteLoading(false);
};
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const customersColumns: ColumnDef<Customer>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC',
},
{
accessorKey: 'type',
header: 'Type',
},
{
accessorKey: 'phone',
header: 'Phone',
},
{
accessorKey: 'email',
header: 'Email',
},
{
header: 'Aksi',
cell: (props: CellContext<Customer, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedCustomer(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.master.customer.create'>
<Button
href='/master-data/customer/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Kandang'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<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.master.customer.create'>
<Button
href='/master-data/customer/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 Customer
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
{/* 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',
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Customer>
data={isResponseSuccess(customers) ? customers?.data : []}
columns={customersColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(customers) ? customers?.meta?.page : 0}
totalItems={
isResponseSuccess(customers) ? customers?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(customers) && customers?.data?.length === 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',
}}
/>
{/* 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(customers) || customers.data?.length === 0 ? (
<div className='p-3'>
<CustomerTableSkeleton
columns={customersColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Customer>
data={customers?.data}
columns={customersColumns}
pageSize={tableFilterState.pageSize}
page={customers?.meta?.page ?? 0}
totalItems={customers?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Customer } from '@/types/api/master-data/customer';
import { ColumnDef } from '@tanstack/react-table';
const CustomerTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no customer data displayed. Enter customer data to get started.',
}: {
columns: ColumnDef<Customer>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default CustomerTableSkeleton;
@@ -1,91 +1,111 @@
'use client';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { Flock } from '@/types/api/master-data/flock';
import { cn } from '@/lib/helper';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { FlockApi } from '@/services/api/master-data';
import { useModal } from '@/components/Modal';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { ROWS_OPTIONS } from '@/config/constant';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
const RowsOptions = ({
type = 'dropdown',
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import FlockTableSkeleton from '@/components/pages/master-data/flock/skeleton/FlockTableSkeleton';
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',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Flock, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `flock#${props.row.original.id}`;
const popoverAnchorName = `--anchor-flock#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.flocks.detail'>
<Button
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon
icon='mdi:eye-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.update'>
<Button
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.master.flocks.detail'>
<Button
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.update'>
<Button
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:pencil-square' width={20} height={20} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const FlockTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -93,15 +113,17 @@ const FlockTable = () => {
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
initial: {
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
// Fetch Data
const [sorting, setSorting] = useState<SortingState>([]);
const {
data: flocks,
isLoading,
@@ -111,74 +133,25 @@ const FlockTable = () => {
FlockApi.getAllFetcher
);
// State
const deleteModal = useModal();
const [selectedFlock, setSelectedFlock] = useState<Flock | undefined>(
undefined
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// Columns Definition
const flocksColumns: ColumnDef<Flock>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString(),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
useEffect(() => {
setTableState('flocks-table', pathname);
}, [pathname, setTableState]);
const deleteClickHandler = () => {
setSelectedFlock(props.row.original);
deleteModal.openModal();
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowsOptions
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowsOptions
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
// Handler
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -196,85 +169,143 @@ const FlockTable = () => {
toast.success('Successfully delete Flock!');
setIsDeleteLoading(false);
};
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const flocksColumns: ColumnDef<Flock>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString('id-ID'),
},
{
header: 'Aksi',
cell: (props: CellContext<Flock, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedFlock(props.row.original);
deleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.master.flocks.create'>
<Button
href='/master-data/flock/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Flock'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<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.master.flocks.create'>
<Button
href='/master-data/flock/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 Flock
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
{/* 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',
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Flock>
data={isResponseSuccess(flocks) ? flocks?.data : []}
columns={flocksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(flocks) ? flocks?.meta?.page : 0}
totalItems={
isResponseSuccess(flocks) ? flocks?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(flocks) && flocks?.data?.length === 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',
}}
/>
{/* 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(flocks) || flocks.data?.length === 0 ? (
<div className='p-3'>
<FlockTableSkeleton
columns={flocksColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Flock>
data={flocks?.data}
columns={flocksColumns}
pageSize={tableFilterState.pageSize}
page={flocks?.meta?.page ?? 0}
totalItems={flocks?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Supplier ini (${selectedFlock?.name})?`}
text={`Apakah anda yakin ingin menghapus data Flock ini (${selectedFlock?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Flock } from '@/types/api/master-data/flock';
import { ColumnDef } from '@tanstack/react-table';
const FlockTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no flock data displayed. Enter flock data to get started.',
}: {
columns: ColumnDef<Flock>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default FlockTableSkeleton;
@@ -1,90 +1,130 @@
'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
ChangeEventHandler,
useCallback,
useEffect,
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';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import KandangTableSkeleton from '@/components/pages/master-data/kandang/skeleton/KandangTableSkeleton';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data';
import { cn, formatNumber } from '@/lib/helper';
import { Location } from '@/types/api/master-data/location';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import { UserApi } from '@/services/api/user';
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 { ROWS_OPTIONS } from '@/config/constant';
import { useUiStore } from '@/stores/ui/ui.store';
import {
KandangFilterSchema,
KandangFilterType,
} from '@/components/pages/master-data/kandang/filter/KandangFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Kandang, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `kandang#${props.row.original.id}`;
const popoverAnchorName = `--anchor-kandang#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.kandangs.detail'>
<Button
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<RequirePermission permissions='lti.master.kandangs.update'>
<Button
href={`/master-data/kandang/detail/edit/?kandangId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.kandangs.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.master.kandangs.detail'>
<Button
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.kandangs.update'>
<Button
href={`/master-data/kandang/detail/edit/?kandangId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:pencil-square' width={20} height={20} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.kandangs.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const KandangsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -94,21 +134,112 @@ const KandangsTable = () => {
} = useTableFilter({
initial: {
search: '',
nameSort: '',
locationSort: '',
capacitySort: '',
picSort: '',
locationFilter: '',
picFilter: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
locationSort: 'sort_location',
capacitySort: 'sort_capacity',
picSort: ' sort_pic',
locationFilter: 'location_id',
picFilter: 'pic_id',
},
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<KandangFilterType>({
initialValues: {
location_id: null,
pic_id: null,
},
validationSchema: KandangFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('locationFilter', values.location_id || '');
updateFilter('picFilter', values.pic_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('locationFilter', '');
updateFilter('picFilter', '');
},
});
// ===== LOCATION OPTIONS =====
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(
filterModal.open ? LocationApi.basePath : null,
'id',
'name',
'search'
);
// ===== PIC OPTIONS =====
const {
setInputValue: setPicInputValue,
options: picOptions,
isLoadingOptions: isLoadingPicOptions,
loadMore: loadMorePics,
} = useSelect<User>(
filterModal.open ? UserApi.basePath : null,
'id',
'name',
'search'
);
// ===== FILTER HANDLERS =====
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]
);
const handleFilterPicChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const pic = val as OptionType | null;
const picId = pic?.value ? String(pic.value) : null;
formik.setFieldValue('pic_id', picId);
},
[formik]
);
// ===== FILTER HELPERS =====
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const picIdValue = useMemo(() => {
if (!formik.values.pic_id) return null;
return (
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
null
);
}, [formik.values.pic_id, picOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
const [sorting, setSorting] = useState<SortingState>([]);
const {
data: kandangs,
isLoading,
@@ -119,82 +250,23 @@ const KandangsTable = () => {
);
const deleteModal = useModal();
const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>(
undefined
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const kandangsColumns: ColumnDef<Kandang>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location.name,
},
{
accessorKey: 'capacity',
header: 'Kapasitas',
cell: (props) => formatNumber(props.row.original.capacity ?? 0),
},
{
accessorKey: 'pic',
header: 'PIC',
cell: (props) => props.row.original.pic.name,
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
useEffect(() => {
setTableState('kandangs-table', pathname);
}, [pathname, setTableState]);
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedKandang(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -216,114 +288,154 @@ const KandangsTable = () => {
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const kandangsColumns: ColumnDef<Kandang>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorFn: (row) => row.location?.name ?? '-',
header: 'Lokasi',
},
{
accessorKey: 'capacity',
header: 'Kapasitas',
cell: (props) => formatNumber(props.row.original.capacity ?? 0),
},
{
accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC',
},
{
accessorFn: (row) => row.kandang_group?.name ?? '-',
header: 'Kandang Group',
},
{
header: 'Aksi',
cell: (props: CellContext<Kandang, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
setPageSize(newVal.value as number);
};
const deleteClickHandler = () => {
setSelectedKandang(props.row.original);
deleteModal.openModal();
};
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const locationSortFilter = sorting.find(
(sortItem) => sortItem.id === 'location'
);
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.master.kandangs.create'>
<Button
href='/master-data/kandang/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Kandang'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<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.master.kandangs.create'>
<Button
href='/master-data/kandang/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 Kandang
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
{/* 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: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',
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
</div>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(kandangs) && kandangs?.data?.length === 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',
}}
/>
{/* 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(kandangs) || kandangs.data?.length === 0 ? (
<div className='p-3'>
<KandangTableSkeleton
columns={kandangsColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Kandang>
data={kandangs?.data}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={kandangs?.meta?.page ?? 0}
totalItems={kandangs?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
@@ -340,6 +452,81 @@ const KandangsTable = () => {
onClick: confirmationModalDeleteClickHandler,
}}
/>
{/* 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={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationIdValue}
onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
onMenuScrollToBottom={loadMoreLocations}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='PIC'
placeholder='Pilih PIC'
options={picOptions}
value={picIdValue}
onChange={handleFilterPicChange}
onInputChange={setPicInputValue}
isLoading={isLoadingPicOptions}
isClearable
onMenuScrollToBottom={loadMorePics}
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='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();
}}
>
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>
</>
);
};
@@ -0,0 +1,11 @@
import { string, object } from 'yup';
export const KandangFilterSchema = object().shape({
location_id: string().nullable(),
pic_id: string().nullable(),
});
export type KandangFilterType = {
location_id: string | null;
pic_id: string | null;
};
@@ -1,3 +1,4 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
type KandangFormSchemaType = {
@@ -19,6 +20,7 @@ type KandangFormSchemaType = {
}
| undefined
| null;
group?: OptionType;
};
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
@@ -42,6 +44,11 @@ export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
group: Yup.object({
value: Yup.number().min(1).required('Kandang Grup wajib diisi!'),
label: Yup.string().required('Kandang Grup wajib diisi!'),
}).required('Kandang Grup wajib diisi!'),
});
export const UpdateKandangFormSchema = KandangFormSchema;
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { getIn, useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
@@ -34,6 +34,8 @@ import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
interface KandangFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -96,6 +98,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
label: initialValues.pic.name,
}
: null,
group: initialValues?.kandang_group
? {
value: initialValues.kandang_group.id,
label: initialValues.kandang_group.name,
}
: undefined,
};
}, [initialValues]);
@@ -111,6 +119,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
location_id: values.locationId!,
capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
pic_id: values.picId!,
group_id: values.group?.value as number,
};
switch (type) {
@@ -162,6 +171,23 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
formik.setFieldValue('picId', (val as OptionType)?.value);
};
// Kandang Group
const {
setInputValue: setKandangGroupSelectInputValue,
options: kandangGroupOptions,
isLoadingOptions: isLoadingKandangGroupOptions,
loadMore: loadMoreKandangGroups,
} = useSelect<DailyChecklistKandang>(
DailyChecklistKandangApi.basePath,
'id',
'name'
);
const kandangGroupChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('group', true);
formik.setFieldValue('group', val);
};
const deleteKandangClickHandler = () => {
deleteModal.openModal();
};
@@ -269,6 +295,24 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
required
label='Kandang Group'
value={formik.values.group ?? undefined}
onChange={kandangGroupChangeHandler}
options={kandangGroupOptions}
onInputChange={setKandangGroupSelectInputValue}
onMenuScrollToBottom={loadMoreKandangGroups}
isLoading={isLoadingKandangGroupOptions}
isError={formik.touched.group && Boolean(formik.errors.group)}
errorMessage={
getIn(formik.errors.group, 'value') ??
(formik.errors.group as string)
}
isDisabled={type === 'detail'}
isClearable
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Kandang } from '@/types/api/master-data/kandang';
import { ColumnDef } from '@tanstack/react-table';
const KandangTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no kandang data displayed. Enter kandang data to get started.',
}: {
columns: ColumnDef<Kandang>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default KandangTableSkeleton;
@@ -1,90 +1,126 @@
'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
ChangeEventHandler,
useCallback,
useEffect,
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';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import LocationTableSkeleton from '@/components/pages/master-data/location/skeleton/LocationTableSkeleton';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { Location } from '@/types/api/master-data/location';
import { LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
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 { ROWS_OPTIONS } from '@/config/constant';
import { useUiStore } from '@/stores/ui/ui.store';
import {
LocationFilterSchema,
LocationFilterType,
} from '@/components/pages/master-data/location/filter/LocationFilter';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Location, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `location#${props.row.original.id}`;
const popoverAnchorName = `--anchor-location#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.locations.detail'>
<Button
href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<RequirePermission permissions='lti.master.locations.update'>
<Button
href={`/master-data/location/detail/edit/?locationId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.locations.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.master.locations.detail'>
<Button
href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.locations.update'>
<Button
href={`/master-data/location/detail/edit/?locationId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:pencil-square' width={20} height={20} />
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.locations.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full text-error hover:text-error'
>
<Icon icon='heroicons:trash' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const LocationsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -92,16 +128,75 @@ const LocationsTable = () => {
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '', addressSort: '', areaSort: '' },
initial: {
search: '',
areaFilter: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
addressSort: 'sort_address',
areaSort: ' sort_area',
areaFilter: 'area_id',
},
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<LocationFilterType>({
initialValues: {
area_id: null,
},
validationSchema: LocationFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
},
});
// ===== AREA OPTIONS =====
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(
filterModal.open ? AreaApi.basePath : null,
'id',
'name',
'search'
);
// ===== 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]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
const {
data: locations,
isLoading,
@@ -112,76 +207,25 @@ const LocationsTable = () => {
);
const deleteModal = useModal();
const [selectedLocation, setSelectedLocation] = useState<
Location | undefined
>(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 locationsColumns: ColumnDef<Location>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'address',
header: 'Alamat',
},
{
accessorKey: 'area',
header: 'Area',
cell: (props) => props.row.original.area.name,
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedLocation(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -203,114 +247,145 @@ const LocationsTable = () => {
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const locationsColumns: ColumnDef<Location>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'address',
header: 'Alamat',
},
{
accessorFn: (row) => row.area?.name ?? '-',
header: 'Area',
},
{
header: 'Aksi',
cell: (props: CellContext<Location, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
setPageSize(newVal.value as number);
};
const deleteClickHandler = () => {
setSelectedLocation(props.row.original);
deleteModal.openModal();
};
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const addressSortFilter = sorting.find(
(sortItem) => sortItem.id === 'address'
);
const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('addressSort', addressSortFilter);
updateSortingFilter('areaSort', areaSortFilter);
}, [sorting, updateSortingFilter]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.master.locations.create'>
<Button
href='/master-data/location/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Location'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<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.master.locations.create'>
<Button
href='/master-data/location/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 Location
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
{/* 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: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',
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
</div>
<Table<Location>
data={isResponseSuccess(locations) ? locations?.data : []}
columns={locationsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(locations) ? locations?.meta?.page : 0}
totalItems={
isResponseSuccess(locations) ? locations?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(locations) && locations?.data?.length === 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',
}}
/>
{/* 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(locations) || locations.data?.length === 0 ? (
<div className='p-3'>
<LocationTableSkeleton
columns={locationsColumns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<Location>
data={locations?.data}
columns={locationsColumns}
pageSize={tableFilterState.pageSize}
page={locations?.meta?.page ?? 0}
totalItems={locations?.meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={false}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: 'p-3 mb-0',
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
</div>
<ConfirmationModal
@@ -327,6 +402,68 @@ const LocationsTable = () => {
onClick: confirmationModalDeleteClickHandler,
}}
/>
{/* 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={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={areaIdValue}
onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions}
isClearable
onMenuScrollToBottom={loadMoreAreas}
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='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();
}}
>
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>
</>
);
};
@@ -0,0 +1,9 @@
import { string, object } from 'yup';
export const LocationFilterSchema = object().shape({
area_id: string().nullable(),
});
export type LocationFilterType = {
area_id: string | null;
};
@@ -0,0 +1,37 @@
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { Location } from '@/types/api/master-data/location';
import { ColumnDef } from '@tanstack/react-table';
const LocationTableSkeleton = ({
columns,
icon,
title = 'No Data Available',
subtitle = 'There is no location data displayed. Enter location data to get started.',
}: {
columns: ColumnDef<Location>[];
icon: React.ReactNode;
title?: string;
subtitle?: string;
}) => {
return (
<div className='relative size-full'>
<Table
data={[]}
columns={columns}
isLoading={true}
className={{
skeletonCellClassName: 'animate-none w-full h-5 bg-base-content/4',
headerColumnClassName: 'whitespace-nowrap',
containerClassName: 'mb-0 overflow-hidden',
tableWrapperClassName: 'overflow-hidden',
}}
/>
<div className='absolute inset-0 flex items-center justify-center'>
<DataStateSkeleton icon={icon} title={title} description={subtitle} />
</div>
</div>
);
};
export default LocationTableSkeleton;

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