Compare commits

..

263 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
Adnan Zahir 53e018aece Merge branch 'staging' into 'production'
refactor(FE): Add tab state management and skeleton for

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

See merge request mbugroup/lti-web-client!37
2025-10-28 08:44:08 +00:00
111 changed files with 5133 additions and 2166 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
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM public.ecr.aws/docker/library/node:20-alpine
RUN apk add --no-cache git bash build-base curl
@@ -22,4 +22,4 @@ RUN mkdir -p .next/server/app/_next && \
EXPOSE 3000
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
+3 -1
View File
@@ -7,8 +7,10 @@
"build": "next build --turbopack",
"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;
@@ -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) {
+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
@@ -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);
}
+24 -16
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;
@@ -566,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,
@@ -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>
@@ -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 || '-';
@@ -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,68 +156,43 @@ 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(() => {
resetForm();
resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards');
setSelectedLocationIds([]);
}, [resetForm, resetFilterValues]);
const handleApplyFilter = useCallback(
(values: DashboardFilter) => {
// Build query params object, only include non-empty values
const params: Record<string, string> = {};
if (values.start_date) params.start_date = values.start_date;
if (values.end_date) params.end_date = values.end_date;
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
if (values.location_ids.length > 0)
params.location_ids = values.location_ids.toString();
if (values.flock_ids.length > 0)
params.flock_ids = values.flock_ids.toString();
if (values.kandang_ids.length > 0)
params.kandang_ids = values.kandang_ids.toString();
if (values.comparison_type)
params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
filterModal.closeModal();
refreshDashboardProductionData();
},
[filterModal, refreshDashboardProductionData]
);
// ===== Load filter from store on mount =====
useEffect(() => {
if (!filterValues) return;
handleApplyFilter({
start_date: filterValues.startDate,
end_date: filterValues.endDate,
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType,
});
}, [filterValues, handleApplyFilter]);
filterModal.closeModal();
}, [filterModal, resetForm, resetFilterValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
@@ -268,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'>
@@ -327,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 */}
@@ -368,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
@@ -537,6 +531,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -573,6 +568,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -604,6 +600,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -643,6 +640,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -669,6 +667,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -707,6 +706,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -733,6 +733,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -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} />
+94 -14
View File
@@ -1,7 +1,13 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
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 {
@@ -31,19 +37,32 @@ import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTab
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 { BaseApiResponse } from '@/types/api/api-general';
type ExpenseTableFilters = {
search: string;
nameSort: string;
transactionDate: string;
realizationDate: string;
locationId: string;
vendorId: string;
userId: string;
};
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
returnToSearchParams,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<Expense, unknown>;
deleteClickHandler: () => void;
returnToSearchParams: URLSearchParams;
}) => {
const popoverId = `expense#${props.row.original.id}`;
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
@@ -86,7 +105,11 @@ const RowOptionsMenu = ({
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.expense.detail'>
<Button
href={`/expense/detail/?expenseId=${props.row.original.id}`}
href={buildExpenseActionHref(
'/expense/detail/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
@@ -100,7 +123,11 @@ const RowOptionsMenu = ({
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
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'
@@ -115,7 +142,11 @@ const RowOptionsMenu = ({
{showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
href={buildExpenseActionHref(
'/expense/realization/',
props.row.original.id,
returnToSearchParams
)}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
@@ -155,6 +186,8 @@ const RowOptionsMenu = ({
const ExpensesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const {
state: tableFilterState,
@@ -162,9 +195,11 @@ const ExpensesTable = () => {
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: '',
@@ -193,6 +228,54 @@ 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();
@@ -373,6 +456,7 @@ const ExpensesTable = () => {
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props}
deleteClickHandler={deleteClickHandler}
returnToSearchParams={returnToSearchParams}
/>
);
},
@@ -512,10 +596,6 @@ const ExpensesTable = () => {
setIsRejectLoading(false);
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('expense-table', pathname);
}, [pathname, setTableState]);
@@ -554,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');
}
@@ -734,8 +814,8 @@ const ExpensesTable = () => {
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onPageChange={pageChangeHandler}
onPageSizeChange={pageSizeChangeHandler}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -35,6 +35,7 @@ import { isResponseError } from '@/lib/api-helper';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
@@ -48,6 +49,8 @@ const ExpenseRealizationForm = ({
initialValues,
}: ExpenseRealizationFormProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
@@ -64,9 +67,9 @@ const ExpenseRealizationForm = ({
}
toast.success(createExpenseRes?.message as string);
router.push('/expense');
router.push(returnTo);
},
[router, initialValues?.id]
[initialValues?.id, returnTo, router]
);
const updateExpenseHandler = useCallback(
@@ -83,9 +86,9 @@ const ExpenseRealizationForm = ({
toast.success(updateExpenseRes?.message as string);
router.refresh();
router.push('/expense');
router.push(returnTo);
},
[router]
[returnTo, router]
);
const formik = useFormik<ExpenseRealizationFormValues>({
@@ -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
@@ -258,7 +261,7 @@ const ExpenseRealizationForm = ({
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
href={returnTo}
variant='link'
className='w-fit p-0 text-primary'
>
@@ -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
/>
@@ -178,14 +178,14 @@ const ExpenseRequestForm = ({
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreVendorOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -410,13 +410,13 @@ const ExpenseRequestForm = ({
options={locationOptions}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isClearable
className={{ wrapper: 'col-span-12 sm:col-span-4' }}
onMenuScrollToBottom={loadMoreLocationOptions}
/>
<DateInput
@@ -455,12 +455,12 @@ const ExpenseRequestForm = ({
options={supplierOptions}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreSuppliers}
isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
}
errorMessage={formik.errors.supplier_id as string}
className={{ wrapper: 'col-span-12' }}
onMenuScrollToBottom={loadMoreVendorOptions}
/>
<RequirePermission permissions='lti.expense.document'>
+561 -238
View File
@@ -1,154 +1,212 @@
'use client';
import React from 'react';
import { Document, Page, StyleSheet, View, Text } from '@react-pdf/renderer';
import {
Document,
Image,
Link,
Page,
StyleSheet,
Text,
View,
} from '@react-pdf/renderer';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfPageNumber } from '@/components/helper/pdf/layout/PdfPageNumber';
import { PdfTable, PdfColumn } from '@/components/helper/pdf/table';
interface ExpensePDFProps {
expense?: Expense;
}
const styles = StyleSheet.create({
const ExpensePDFStyle = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
paddingTop: 24,
paddingBottom: 64,
paddingHorizontal: 32,
},
titleSection: {
marginBottom: 10,
},
parameterContainer: {
companyInfoHeader: {
width: '100%',
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
infoTableSection: {
marginBottom: 12,
companyLogo: {
width: 64,
height: 'auto',
},
infoTableTitle: {
fontSize: 10,
companyInfoHeaderDate: {
paddingTop: 8,
fontSize: 12,
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 6,
color: '#333',
marginBottom: 4,
},
tableSection: {
marginBottom: 12,
},
tableTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
color: '#333',
},
emptyText: {
companyAddress: {
fontSize: 8,
color: '#666',
fontStyle: 'italic',
maxWidth: 400,
marginBottom: 10,
},
title: {
marginTop: 16,
fontSize: 16,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 32,
position: 'absolute',
fontSize: 10,
bottom: 30,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
// wrapper
generalInfoTable: {
width: '100%',
marginTop: 8,
borderWidth: 1,
borderColor: '#000000',
borderBottomWidth: 0,
fontSize: 12,
},
generalInfoTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
},
// columns
generalInfoTableColLabel: {
width: '30%',
paddingVertical: 6,
paddingHorizontal: 8,
},
generalInfoTableColSeparator: {
width: '3%',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 6,
},
generalInfoTableColValue: {
width: '67%',
paddingVertical: 6,
paddingHorizontal: 8,
},
generalInfoTableLabelText: {
fontWeight: 'bold',
},
generalInfoTableValueText: {},
// expense detail table
expenseDetailContainer: {
width: '100%',
marginTop: 12,
},
expenseDetailTitle: {
fontSize: 14,
lineHeight: '150%',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
textAlign: 'center',
},
kandangExpenseContainer: {
width: '100%',
marginTop: 8,
},
kandangExpenseTitle: {
fontSize: 14,
lineHeight: '150%',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
textAlign: 'center',
},
kandangExpenseTable: {
width: '100%',
marginTop: 8,
borderWidth: 1,
borderColor: '#000000',
borderBottomWidth: 0,
fontSize: 12,
},
kandangExpenseTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
},
kandangExpenseTableColLabel: {
width: '20%',
paddingVertical: 6,
paddingHorizontal: 8,
},
kandangExpenseTableColLabelBorderRight: {
borderRight: '1px solid #000000',
},
kandangExpenseTableColNonstock: {
width: '20%',
},
kandangExpenseTableColNote: {
width: '40%',
},
kandangExpenseHeaderLabelText: {
fontWeight: 'bold',
},
kandangExpenseLabelText: {
fontSize: 10,
},
kandangExpenseTableFooterColTotalExpenseCaption: {
width: '40%',
paddingVertical: 6,
paddingHorizontal: 8,
textAlign: 'right',
},
kandangExpenseTableFooterColTotalExpenseValue: {
width: '60%',
paddingVertical: 6,
paddingHorizontal: 8,
},
// utils
doubleDivider: {
width: '100%',
height: 6,
borderTop: '2px solid black',
borderBottom: '2px solid black',
},
});
type ExpenseKandang = Expense['kandangs'][number];
type PengajuanItem = NonNullable<ExpenseKandang['pengajuans']>[number];
type RealisasiItem = NonNullable<ExpenseKandang['realisasi']>[number];
const valueText = (v: unknown) => {
if (v === null || v === undefined) return '-';
if (typeof v === 'number') return formatNumber(v);
return String(v);
};
const getPengajuanColumns = (): PdfColumn<PengajuanItem>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ index }) => index + 1,
},
{
key: 'nonstock',
header: 'Nonstock',
flex: 1.5,
cell: ({ row }) => row.nonstock.name,
},
{
key: 'qty',
header: 'Kuantitas',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.qty),
},
{
key: 'price',
header: 'Harga Satuan',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.price),
},
{
key: 'notes',
header: 'Catatan',
flex: 1.5,
cell: ({ row }) => row.notes || '-',
},
];
const getRealisasiColumns = (): PdfColumn<RealisasiItem>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ index }) => index + 1,
},
{
key: 'nonstock',
header: 'Nonstock',
flex: 1.5,
cell: ({ row }) => row.nonstock.name,
},
{
key: 'qty',
header: 'Kuantitas',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.qty),
},
{
key: 'price',
header: 'Harga Satuan',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.price),
},
{
key: 'notes',
header: 'Catatan',
flex: 1.5,
cell: ({ row }) => row.notes || '-',
},
];
const getInfoTableRows = (expense?: Expense) => {
const ExpensePDF = ({ expense }: ExpensePDFProps) => {
const isLatestApprovalRejected =
expense?.latest_approval?.action === 'REJECTED';
const isExpenseRealized =
expense?.latest_approval?.step_number &&
expense?.latest_approval.step_number >= 5;
const realizationStatus = isExpenseRealized
? 'Sudah Realisasi'
: 'Belum Realisasi';
return [
{ label: 'Nomor PO', value: expense?.po_number || '-' },
{ label: 'Nomor Referensi', value: expense?.reference_number || '-' },
const rows = [
{ label: 'Nomor PO', value: expense?.po_number },
{ label: 'Nomor Referensi', value: expense?.reference_number },
{
label: 'Kategori',
value:
@@ -156,9 +214,9 @@ const getInfoTableRows = (expense?: Expense) => {
? 'Biaya Operasional'
: expense?.category === 'NON-BOP'
? 'Non Biaya Operasional'
: '-',
: '',
},
{ label: 'Lokasi', value: expense?.location?.name || '-' },
{ label: 'Lokasi', value: expense?.location.name },
{
label: 'Kandang',
value:
@@ -169,7 +227,7 @@ const getInfoTableRows = (expense?: Expense) => {
.join(', ')
: '-',
},
{ label: 'Vendor', value: expense?.supplier?.name || '-' },
{ label: 'Vendor', value: expense?.supplier.name },
{
label: 'Tanggal Transaksi',
value: formatDate(expense?.transaction_date, 'DD MMMM YYYY'),
@@ -180,12 +238,12 @@ const getInfoTableRows = (expense?: Expense) => {
? formatDate(expense?.realization_date, 'DD MMMM YYYY')
: '-',
},
{ label: 'Nama Pengaju', value: expense?.created_user?.name || '-' },
{ label: 'Nama Pengaju', value: expense?.created_user.name },
{
label: 'Nominal Biaya',
value: formatCurrency(
expense?.latest_approval?.step_number === 5 ||
expense?.latest_approval?.step_number === 6
expense?.latest_approval.step_number === 5 ||
expense?.latest_approval.step_number === 6
? (expense?.total_realisasi ?? 0)
: (expense?.total_pengajuan ?? 0)
),
@@ -205,136 +263,401 @@ const getInfoTableRows = (expense?: Expense) => {
label: 'Status Biaya',
value: isLatestApprovalRejected
? 'Ditolak'
: expense?.latest_approval?.step_name || '-',
: expense?.latest_approval?.step_name,
},
];
};
interface InfoRow {
label: string;
value: string;
}
const getInfoTableColumns = (): PdfColumn<InfoRow>[] => [
{
key: 'label',
header: 'Field',
flex: 1,
cell: ({ row }) => row.label,
},
{
key: 'value',
header: 'Value',
flex: 2,
cell: ({ row }) => row.value,
},
];
const ExpensePDF = ({ expense }: ExpensePDFProps) => {
const kandangs = expense?.kandangs || [];
const infoRows = getInfoTableRows(expense);
return (
<Document>
<Page style={styles.page} size='A4'>
{/* Title Section */}
<View style={styles.titleSection}>
<PdfTypography size='h1' variant='primary'>
Laporan{' '}
{expense?.category === 'BOP'
? 'Biaya Operasional'
: 'Non-Biaya Operasional'}
</PdfTypography>
<PdfTypography size='h2'>{expense?.po_number || '-'}</PdfTypography>
<View style={styles.parameterContainer}>
<PdfParamBadge>
Tanggal: {formatDate(Date.now(), 'DD MMMM YYYY')}
</PdfParamBadge>
<PdfParamBadge>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</PdfParamBadge>
<Page style={ExpensePDFStyle.page}>
<View>
<View style={ExpensePDFStyle.companyInfoHeader}>
<Image
style={ExpensePDFStyle.companyLogo}
src='/assets/img/lti-logo.png'
/>
<Text style={ExpensePDFStyle.companyInfoHeaderDate}>
{formatDate(Date.now(), 'DD MMMM YYYY')}
</Text>
</View>
<View>
<Text style={ExpensePDFStyle.companyName}>
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={ExpensePDFStyle.companyAddress}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
</Text>
<View style={ExpensePDFStyle.doubleDivider} />
</View>
</View>
{/* Info Table Section */}
<View style={styles.infoTableSection}>
<Text style={styles.infoTableTitle}>Informasi Biaya</Text>
<PdfTable columns={getInfoTableColumns()} data={infoRows} />
<Text style={ExpensePDFStyle.title}>
Laporan{' '}
{expense?.category === 'BOP'
? 'Biaya Operasional'
: 'Non-Biaya Operasional'}{' '}
{expense?.po_number}
</Text>
{/* General info table */}
<View style={ExpensePDFStyle.generalInfoTable}>
{rows.map((row) => (
<View style={ExpensePDFStyle.generalInfoTableRow} key={row.label}>
<View style={ExpensePDFStyle.generalInfoTableColLabel}>
<Text style={ExpensePDFStyle.generalInfoTableLabelText}>
{row.label}
</Text>
</View>
<View style={ExpensePDFStyle.generalInfoTableColSeparator}>
<Text>:</Text>
</View>
<View style={ExpensePDFStyle.generalInfoTableColValue}>
<Text style={ExpensePDFStyle.generalInfoTableValueText}>
{row.value}
</Text>
</View>
</View>
))}
</View>
{/* Rincian Pengajuan Section */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>1. Rincian Pengajuan Biaya</Text>
{kandangs.length === 0 ? (
<Text style={styles.emptyText}>Tidak ada data pengajuan.</Text>
) : (
kandangs.map((kandang, idx) => {
const pengajuans = kandang.pengajuans || [];
const kandangName =
kandang.kandang_id && kandang.name
? kandang.name
: expense?.location?.name || 'Umum';
{/* Detail expense request */}
<View
minPresenceAhead={80}
style={ExpensePDFStyle.expenseDetailContainer}
>
<Text style={ExpensePDFStyle.expenseDetailTitle}>
Rincian Pengajuan Biaya Operasional
</Text>
return (
<View key={idx} style={{ marginBottom: 12 }}>
<PdfTypography size='h3' style={{ paddingLeft: 12 }}>
{idx + 1}) {kandangName}
</PdfTypography>
{pengajuans.length > 0 ? (
<PdfTable
columns={getPengajuanColumns()}
data={pengajuans}
showFooter={true}
footerLabel='Total'
/>
) : (
<Text style={styles.emptyText}>
Tidak ada item pengajuan untuk kandang ini.
</Text>
)}
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.qty * item.price)
);
return (
<View
key={kandangExpenseIdx}
style={ExpensePDFStyle.kandangExpenseContainer}
>
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text>
<View style={ExpensePDFStyle.kandangExpenseTable}>
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Nonstock
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Kuantitas
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Harga Satuan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Catatan
</Text>
</View>
</View>
{kandangExpense.pengajuans?.map((pengajuan, pengajuanIdx) => (
<View
key={pengajuanIdx}
style={ExpensePDFStyle.kandangExpenseTableRow}
>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.nonstock.name}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatNumber(pengajuan.qty)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(pengajuan.price)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.notes}
</Text>
</View>
</View>
))}
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya Keseluruhan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
{formatCurrency(expenseRequestTotal)}
</Text>
</View>
</View>
</View>
);
})
)}
</View>
);
})}
</View>
{/* Rincian Realisasi Section */}
<View style={styles.tableSection}>
<Text style={styles.tableTitle}>2. Rincian Realisasi Biaya</Text>
{kandangs.length === 0 ? (
<Text style={styles.emptyText}>Tidak ada data realisasi.</Text>
) : (
kandangs.map((kandang, idx) => {
const realisasi = kandang.realisasi || [];
const kandangName =
kandang.kandang_id && kandang.name
? kandang.name
: expense?.location?.name || 'Umum';
{/* Detail expense realization */}
<View
minPresenceAhead={80}
style={ExpensePDFStyle.expenseDetailContainer}
>
<Text style={ExpensePDFStyle.expenseDetailTitle}>
Rincian Realisasi Biaya Operasional
</Text>
return (
<View key={idx} style={{ marginBottom: 12 }}>
<PdfTypography size='h3' style={{ paddingLeft: 12 }}>
{idx + 1}) {kandangName}
</PdfTypography>
{realisasi.length > 0 ? (
<PdfTable
columns={getRealisasiColumns()}
data={realisasi}
showFooter={true}
footerLabel='Total'
/>
) : (
<Text style={styles.emptyText}>
Tidak ada item realisasi untuk kandang ini.
</Text>
)}
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.qty * item.price)
);
return (
<View
key={kandangExpenseIdx}
style={ExpensePDFStyle.kandangExpenseContainer}
>
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text>
<View style={ExpensePDFStyle.kandangExpenseTable}>
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Nonstock
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Kuantitas
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Harga Satuan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Catatan
</Text>
</View>
</View>
{kandangExpense.realisasi?.map((realisasi, realisasiIdx) => (
<View
key={realisasiIdx}
style={ExpensePDFStyle.kandangExpenseTableRow}
>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.nonstock.name}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatNumber(realisasi.qty)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(realisasi.price)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.notes}
</Text>
</View>
</View>
))}
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya Keseluruhan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
{formatCurrency(expenseRealizationTotal)}
</Text>
</View>
</View>
</View>
);
})
)}
</View>
);
})}
</View>
<PdfPageNumber />
<View style={ExpensePDFStyle.footer} fixed>
<Link
src={`${process.env.NEXT_PUBLIC_LTI_URL}expense/detail?expenseId=${expense?.id}`}
>
{expense?.po_number}
</Link>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page>
</Document>
);
@@ -8,7 +8,7 @@ import {
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import useSWR, { mutate } from 'swr';
import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useFormik } from 'formik';
@@ -26,6 +26,10 @@ 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';
@@ -38,6 +42,62 @@ import {
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();
@@ -80,13 +140,13 @@ const InventoryAdjustmentTable = () => {
const formik = useFormik<AdjustmentFilterType>({
initialValues: {
product_id: null,
warehouse_id: null,
warehouse: null,
transaction_type: null,
},
validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
updateFilter('warehouseFilter', String(values.warehouse?.value) || '');
updateFilter('transactionTypeFilter', values.transaction_type || '');
filterModal.closeModal();
setSubmitting(false);
@@ -142,14 +202,11 @@ const InventoryAdjustmentTable = () => {
[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]
);
const handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('warehouse', val);
};
const handleFilterTransactionTypeChange = useCallback(
(val: OptionType | OptionType[] | null) => {
@@ -170,15 +227,6 @@ const InventoryAdjustmentTable = () => {
);
}, [formik.values.product_id, productOptions]);
const warehouseIdValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find(
(opt) => String(opt.value) === formik.values.warehouse_id
) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
const transactionTypeValue = useMemo(() => {
if (!formik.values.transaction_type) return null;
return (
@@ -194,12 +242,39 @@ const InventoryAdjustmentTable = () => {
formik.validateForm();
};
const { data: inventoryAdjustments, isLoading } = useSWR(
const {
data: inventoryAdjustments,
isLoading,
mutate: refreshAdjustments,
} = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher
);
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await InventoryAdjustmentApi.delete(
selectedAdjustment?.id as number
);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Adjustment!');
refreshAdjustments();
} else {
toast.error(response?.message || 'Failed to delete Adjustment');
}
};
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedAdjustment, setSelectedAdjustment] = useState<
InventoryAdjustment | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
useEffect(() => {
updateFilter('search', searchValue);
@@ -314,8 +389,39 @@ const InventoryAdjustmentTable = () => {
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(
@@ -502,7 +608,7 @@ const InventoryAdjustmentTable = () => {
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={warehouseIdValue}
value={formik.values.warehouse}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
@@ -544,6 +650,21 @@ const InventoryAdjustmentTable = () => {
</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,
}}
/>
</>
);
};
@@ -1,4 +1,5 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const AdjustmentFilterSchema = object().shape({
product_id: string().nullable(),
@@ -8,6 +9,6 @@ export const AdjustmentFilterSchema = object().shape({
export type AdjustmentFilterType = {
product_id: string | null;
warehouse_id: string | null;
transaction_type: string | null;
warehouse: OptionType<number> | null;
};
@@ -15,7 +15,7 @@ import {
InventoryAdjustmentFormSchema,
InventoryAdjustmentFormValues,
} from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
@@ -32,8 +32,6 @@ import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Kandang } from '@/types/api/master-data/kandang';
import { Product } from '@/types/api/master-data/product';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import { BaseApiResponse } from '@/types/api/api-general';
@@ -119,40 +117,19 @@ const InventoryAdjustmentForm = ({
}
);
const { rawData: approvedProjectFlockKandangsRawData } =
useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'id',
'search',
{
step_name: 'Disetujui',
limit: '100',
}
);
const approvedProjectFlockKandangs = useMemo(() => {
if (
approvedProjectFlockKandangsRawData &&
'data' in approvedProjectFlockKandangsRawData
) {
return approvedProjectFlockKandangsRawData.data as ProjectFlockKandang[];
}
return [];
}, [approvedProjectFlockKandangsRawData]);
const {
setInputValue: setKandangInputValue,
options: kandangOptionsFromApi,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(
selectedProjectFlock ? KandangApi.basePath : '',
'id',
'name',
options: projectFlockKandangOptions,
loadMore: loadMoreProjectFlockKandangs,
setInputValue: setProjectFlockKandangInputValue,
isLoadingOptions: isLoadingProjectFlockKandangOptions,
} = useSelect(
selectedProjectFlock ? ProjectFlockKandangApi.basePath : '',
'kandang.id',
'kandang.name',
'search',
{
location_id: selectedProjectFlockLocationId,
step_name: 'Disetujui',
project_flock_id: String(selectedProjectFlock?.value),
}
);
@@ -185,7 +162,9 @@ const InventoryAdjustmentForm = ({
isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts,
rawData: products,
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search');
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
include_all: 'true',
});
const {
setInputValue: setDepletionProductInputValue,
@@ -220,26 +199,6 @@ const InventoryAdjustmentForm = ({
return (product?.flags as string[]) || [];
}, [selectedProduct, productOptions]);
const kandangOptions = useMemo(() => {
let options: OptionType[] = [];
if (selectedProjectFlock) {
const approvedKandangIds = approvedProjectFlockKandangs
.filter((pfk) => pfk.project_flock_id === selectedProjectFlock.value)
.map((pfk) => pfk.kandang_id);
options = kandangOptionsFromApi.filter((kandang) =>
approvedKandangIds.includes(kandang.value as number)
);
}
return options;
}, [
selectedProjectFlock,
kandangOptionsFromApi,
approvedProjectFlockKandangs,
]);
const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>(
() => ({
location: null,
@@ -691,10 +650,10 @@ const InventoryAdjustmentForm = ({
label='Kandang'
value={selectedKandang}
onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
options={kandangOptions}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangOptions}
onInputChange={setProjectFlockKandangInputValue}
options={projectFlockKandangOptions}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isLoading={isLoadingProjectFlockKandangOptions}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
@@ -8,7 +8,7 @@ import {
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import useSWR, { mutate } from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik';
@@ -21,6 +21,8 @@ import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
@@ -41,9 +43,11 @@ import {
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<Movement, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `movement#${props.row.original.id}`;
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
@@ -83,6 +87,20 @@ const RowOptionsMenu = ({
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>
@@ -206,12 +224,37 @@ const MovementTable = () => {
};
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
const { data: movements, isLoading } = useSWR(
const {
data: movements,
isLoading,
mutate: refreshMovements,
} = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.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]);
@@ -275,16 +318,27 @@ const MovementTable = () => {
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]
[
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedMovement,
]
);
return (
@@ -455,6 +509,21 @@ const MovementTable = () => {
</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,
}}
/>
</>
);
};
@@ -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 =====
@@ -323,8 +324,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
},
});
const { setFieldValue, setFieldTouched, setFieldError } = formik;
const prevSourceWarehouseIdRef = useRef<number | null>(
formik.values.source_warehouse_id
);
@@ -338,14 +337,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null
) {
setFieldValue('products', [
formik.setFieldValue('products', [
{
product: null,
product_id: 0,
product_qty: '',
},
]);
setFieldTouched('products', false);
formik.setFieldTouched('products', false);
const updatedDeliveries = formik.values.deliveries.map(
(delivery: DeliverySchema) => ({
@@ -359,17 +358,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
],
})
);
setFieldValue('deliveries', updatedDeliveries);
setFieldTouched('deliveries', false);
formik.setFieldValue('deliveries', updatedDeliveries);
formik.setFieldTouched('deliveries', false);
}
prevSourceWarehouseIdRef.current = currentSourceWarehouseId;
}, [
formik.values.source_warehouse_id,
formik.values.deliveries,
setFieldValue,
setFieldTouched,
]);
}, [formik.values.source_warehouse_id, formik.values.deliveries]);
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const {
@@ -386,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',
}
);
@@ -398,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]);
@@ -462,9 +459,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== EVENT HANDLERS =====
const handleTransferDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setFieldValue('transfer_date', e.target.value);
formik.setFieldValue('transfer_date', e.target.value);
},
[setFieldValue]
[]
);
const handleSourceWarehouseChange = useCallback(
@@ -484,16 +481,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return;
}
setFieldTouched('source_warehouse', true);
setFieldValue('source_warehouse', val);
setFieldTouched('source_warehouse_id', true);
setFieldValue('source_warehouse_id', newSourceWarehouseId);
formik.setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val);
formik.setFieldTouched('source_warehouse_id', true);
formik.setFieldValue('source_warehouse_id', newSourceWarehouseId);
},
[
formik.values.destination_warehouse_id,
formik.values.destination_warehouse,
setFieldTouched,
setFieldValue,
]
);
@@ -514,17 +509,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return;
}
setFieldTouched('destination_warehouse', true);
setFieldValue('destination_warehouse', val);
setFieldTouched('destination_warehouse_id', true);
setFieldValue('destination_warehouse_id', newDestinationWarehouseId);
formik.setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val);
formik.setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue(
'destination_warehouse_id',
newDestinationWarehouseId
);
},
[
formik.values.source_warehouse_id,
formik.values.source_warehouse,
setFieldTouched,
setFieldValue,
]
[formik.values.source_warehouse_id, formik.values.source_warehouse]
);
const addProduct = useCallback(() => {
@@ -536,15 +529,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
product_qty: '',
},
];
setFieldValue('products', newProducts);
}, [formik.values.products, setFieldValue]);
formik.setFieldValue('products', newProducts);
}, [formik.values.products]);
const removeProduct = useCallback(
(i: number) => {
const updatedProducts = formik.values.products?.filter(
(_, idx) => idx !== i
);
setFieldValue('products', updatedProducts);
formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]);
@@ -553,12 +546,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setProductQtyErrorShown(false);
}
},
[
formik.values.products,
productQtyErrorShown,
setSelectedProducts,
setFieldValue,
]
[formik.values.products, productQtyErrorShown, setSelectedProducts]
);
const bulkRemoveProduct = useCallback(() => {
@@ -566,32 +554,26 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.values.products?.filter(
(_, idx) => !selectedProducts.includes(idx)
) ?? [];
setFieldValue('products', updatedProducts);
formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]);
if (productQtyErrorShown) {
toast.dismiss();
setProductQtyErrorShown(false);
}
}, [
selectedProducts,
setSelectedProducts,
productQtyErrorShown,
setFieldValue,
formik.values.products,
]);
}, [formik, selectedProducts, setSelectedProducts, productQtyErrorShown]);
const handleProductChange = useCallback(
(idx: number, val: OptionType | OptionType[] | null) => {
setFieldTouched(`products.${idx}.product`, true);
setFieldValue(`products.${idx}.product`, val);
setFieldTouched(`products.${idx}.product_id`, true);
setFieldValue(
formik.setFieldTouched(`products.${idx}.product`, true);
formik.setFieldValue(`products.${idx}.product`, val);
formik.setFieldTouched(`products.${idx}.product_id`, true);
formik.setFieldValue(
`products.${idx}.product_id`,
(val as ProductWarehouseOptionType)?.value
);
},
[setFieldTouched, setFieldValue]
[]
);
const handleProductSelectAllChange = useCallback(
@@ -618,7 +600,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
const addDelivery = useCallback(() => {
setFieldValue('deliveries', [
formik.setFieldValue('deliveries', [
...(formik.values.deliveries || []),
{
delivery_cost: '',
@@ -637,14 +619,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
],
},
]);
}, [formik.values.deliveries, setFieldValue]);
}, [formik.values.deliveries]);
const removeDelivery = useCallback(
(i: number) => {
const updatedDeliveries = formik.values.deliveries?.filter(
(_, idx) => idx !== i
);
setFieldValue('deliveries', updatedDeliveries);
formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]);
@@ -653,12 +635,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setDeliveryQtyErrorShown(false);
}
},
[
formik.values.deliveries,
deliveryQtyErrorShown,
setSelectedDeliveries,
setFieldValue,
]
[formik.values.deliveries, deliveryQtyErrorShown, setSelectedDeliveries]
);
const bulkRemoveDelivery = useCallback(() => {
@@ -666,7 +643,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.values.deliveries?.filter(
(_, idx) => !selectedDeliveries.includes(idx)
) ?? [];
setFieldValue('deliveries', updatedDeliveries);
formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]);
if (deliveryQtyErrorShown) {
@@ -674,11 +651,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setDeliveryQtyErrorShown(false);
}
}, [
formik,
selectedDeliveries,
setSelectedDeliveries,
deliveryQtyErrorShown,
setFieldValue,
formik.values.deliveries,
]);
const handleDeliverySelectAllChange = useCallback(
@@ -708,28 +684,34 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const handleDeliveryProductChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
setFieldTouched(`deliveries.${deliveryIdx}.products.0.product`, true);
setFieldValue(`deliveries.${deliveryIdx}.products.0.product`, val);
setFieldTouched(`deliveries.${deliveryIdx}.products.0.product_id`, true);
setFieldValue(
formik.setFieldTouched(
`deliveries.${deliveryIdx}.products.0.product`,
true
);
formik.setFieldValue(`deliveries.${deliveryIdx}.products.0.product`, val);
formik.setFieldTouched(
`deliveries.${deliveryIdx}.products.0.product_id`,
true
);
formik.setFieldValue(
`deliveries.${deliveryIdx}.products.0.product_id`,
(val as OptionType)?.value
);
},
[setFieldTouched, setFieldValue]
[]
);
const handleDeliverySupplierChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
setFieldTouched(`deliveries.${deliveryIdx}.supplier`, true);
setFieldValue(`deliveries.${deliveryIdx}.supplier`, val);
setFieldTouched(`deliveries.${deliveryIdx}.supplier_id`, true);
setFieldValue(
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier`, true);
formik.setFieldValue(`deliveries.${deliveryIdx}.supplier`, val);
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier_id`, true);
formik.setFieldValue(
`deliveries.${deliveryIdx}.supplier_id`,
(val as OptionType)?.value
);
},
[setFieldTouched, setFieldValue]
[]
);
const handleDeliveryDocumentChange = useCallback(
@@ -741,15 +723,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
e.target.value = '';
return;
}
setFieldValue(`deliveries.${deliveryIdx}.document`, file);
formik.setFieldValue(`deliveries.${deliveryIdx}.document`, file);
}
},
[setFieldValue]
[]
);
const handleDeliveryCostChange = useCallback(
(idx: number, value: number) => {
setFieldValue(`deliveries.${idx}.delivery_cost`, value);
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value);
const delivery = formik.values.deliveries?.[idx];
if (delivery) {
@@ -759,18 +741,21 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
if (productQty > 0 && value > 0) {
const perItem = value / productQty;
setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, perItem);
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (value === 0) {
setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
}
}
},
[formik.values.deliveries, setFieldValue]
[formik.values.deliveries]
);
const handleDeliveryCostPerItemChange = useCallback(
(idx: number, value: number) => {
setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
const delivery = formik.values.deliveries?.[idx];
if (delivery) {
@@ -780,13 +765,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
if (productQty > 0 && value > 0) {
const totalCost = value * productQty;
setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
} else if (value === 0) {
setFieldValue(`deliveries.${idx}.delivery_cost`, 0);
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, 0);
}
}
},
[formik.values.deliveries, setFieldValue]
[formik.values.deliveries]
);
const handleDeliveryCostChangeWrapper = useCallback(
@@ -853,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(
@@ -863,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;
@@ -870,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(
@@ -941,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(
@@ -1063,7 +1100,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return !validateDeliveryQty(deliveryIdx, productIdx, qty);
})
) ?? []),
[formik.values.deliveries, validateDeliveryQty, type]
[
formik.values.deliveries,
formik.values.products,
validateDeliveryQty,
type,
]
);
const hasInvalidQty = useMemo(
@@ -1080,27 +1122,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
}, [formik.values.products, getProductQtyError, type]);
const deliveryCostDepString = useMemo(
() =>
formik.values.deliveries
?.map((d, idx) => ({
idx,
productQty: d.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
),
deliveryCost: parseInt((d.delivery_cost || '').toString()) || 0,
deliveryCostPerItem:
parseInt((d.delivery_cost_per_item || '').toString()) || 0,
}))
.map(
(item) =>
`${item.idx}:${item.productQty}:${item.deliveryCost}:${item.deliveryCostPerItem}`
)
.join('|'),
[formik.values.deliveries]
);
// ===== EFFECTS =====
useEffect(() => {
formik.values.deliveries?.forEach((delivery, idx) => {
@@ -1117,16 +1138,36 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (deliveryCost > 0 && productQty > 0) {
const perItem = deliveryCost / productQty;
if (Math.abs(deliveryCostPerItem - perItem) > 0.01) {
setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, perItem);
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
}
} else if (deliveryCostPerItem > 0 && productQty > 0) {
const totalCost = deliveryCostPerItem * productQty;
if (Math.abs(deliveryCost - totalCost) > 0.01) {
setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
}
}
});
}, [deliveryCostDepString, setFieldValue, formik.values.deliveries]);
}, [
formik.values.deliveries
?.map((d, idx) => ({
idx,
productQty: d.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
),
deliveryCost: parseInt((d.delivery_cost || '').toString()) || 0,
deliveryCostPerItem:
parseInt((d.delivery_cost_per_item || '').toString()) || 0,
}))
.map(
(item) =>
`${item.idx}:${item.productQty}:${item.deliveryCost}:${item.deliveryCostPerItem}`
)
.join('|'),
]);
useEffect(() => {
if (
@@ -1136,7 +1177,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
!isInitialized
) {
if (formik.values.products.length === 0) {
setFieldValue('products', [
formik.setFieldValue('products', [
{
product: null,
product_id: 0,
@@ -1145,7 +1186,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
]);
}
if (formik.values.deliveries.length === 0) {
setFieldValue('deliveries', [
formik.setFieldValue('deliveries', [
{
delivery_cost: undefined,
delivery_cost_per_item: undefined,
@@ -1167,14 +1208,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}
setIsInitialized(true);
}
}, [
formik.values.source_warehouse_id,
isInitialized,
type,
setFieldValue,
formik.values.products.length,
formik.values.deliveries.length,
]);
}, [formik.values.source_warehouse_id, isInitialized, type]);
useEffect(() => {
if (
@@ -1183,7 +1217,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.values.source_warehouse_id ===
formik.values.destination_warehouse_id
) {
setFieldError(
formik.setFieldError(
'destination_warehouse_id',
'Gudang tujuan tidak boleh sama dengan gudang asal!'
);
@@ -1192,14 +1226,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
setFieldError('destination_warehouse_id', undefined);
formik.setFieldError('destination_warehouse_id', undefined);
}
}
}, [
formik.values.source_warehouse_id,
formik.values.destination_warehouse_id,
formik.errors.destination_warehouse_id,
setFieldError,
]);
useEffect(() => {
@@ -1235,37 +1268,29 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
if (hasChanges) {
setFieldValue('deliveries', updatedDeliveries);
formik.setFieldValue('deliveries', updatedDeliveries);
}
}
}, [formik.values.products, formik.values.deliveries, setFieldValue]);
const productQtyDepString = useMemo(
() => formik.values.products?.map((p) => p.product_qty).join(','),
[formik.values.products]
);
}, [formik.values.products]);
useEffect(() => {
if (productQtyErrorShown) {
toast.dismiss();
setProductQtyErrorShown(false);
}
}, [productQtyErrorShown]);
const deliveryProductQtyDepString = useMemo(
() =>
formik.values.deliveries
?.map((d) => d.products.map((p) => p.product_qty).join(','))
.join('|'),
[formik.values.deliveries]
);
}, [formik.values.products?.map((p) => p.product_qty).join(',')]);
useEffect(() => {
if (deliveryQtyErrorShown) {
toast.dismiss();
setDeliveryQtyErrorShown(false);
}
}, [deliveryProductQtyDepString, productQtyDepString, deliveryQtyErrorShown]);
}, [
formik.values.deliveries
?.map((d) => d.products.map((p) => p.product_qty).join(','))
.join('|'),
formik.values.products?.map((p) => p.product_qty).join(','),
]);
useEffect(() => {
if (hasExceededStock && !productQtyErrorShown && type !== 'detail') {
@@ -199,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)
),
};
}
})
@@ -368,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);
}
@@ -430,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)
),
};
}
})
@@ -536,13 +544,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
formModal.closeModal();
};
const hasLoadedInitialValues = useRef(false);
useEffect(() => {
const getFilledInitialValues = async () => {
if (marketingId && isResponseSuccess(marketing)) {
if (hasLoadedInitialValues.current) return;
hasLoadedInitialValues.current = true;
const filledInitialValues = await getFilledMarketingFormInitialValues(
marketing.data
);
@@ -586,15 +590,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
setFormErrorMessage('');
}, [step]);
const prevDeliveryOrderValuesRef = useRef(deliveryOrderValues);
// sync delivery order values to formik
useEffect(() => {
if (
JSON.stringify(prevDeliveryOrderValuesRef.current) !==
JSON.stringify(deliveryOrderValues)
) {
prevDeliveryOrderValuesRef.current = deliveryOrderValues;
formik.setFieldValue('delivery_order', deliveryOrderValues);
}
formik.setFieldValue('delivery_order', deliveryOrderValues);
}, [deliveryOrderValues]);
const grandTotal = useMemo(() => {
@@ -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}
@@ -226,6 +226,11 @@ const MarketingTable = () => {
confirmationModal.openModal();
};
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete(
selectedItem?.id as number
@@ -445,11 +450,6 @@ const MarketingTable = () => {
accessorKey: 'marketing_products.length',
header: 'Product Details',
cell: (props) => {
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) {
return (
@@ -504,7 +504,7 @@ const MarketingTable = () => {
},
},
];
}, [deleteModal, deliveryModal, setSelectedItem, productsModal]);
}, []);
return (
<>
@@ -746,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)),
@@ -458,13 +459,9 @@ const SalesOrderFormModal = ({
);
}, [memoSalesOrder]);
const hasLoadedInitialValues = useRef(false);
useEffect(() => {
const getFilledInitialValues = async () => {
if (marketingId && isResponseSuccess(marketing)) {
if (hasLoadedInitialValues.current) return;
hasLoadedInitialValues.current = true;
const filledInitialValues = await getFilledMarketingFormInitialValues(
marketing.data
);
@@ -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;
});
};
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
@@ -32,6 +32,63 @@ 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,
@@ -76,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
@@ -112,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;
@@ -154,6 +211,27 @@ const DeliveryOrderProductForm = ({
(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: {
@@ -167,8 +245,7 @@ const DeliveryOrderProductForm = ({
undefined,
marketing_product_id:
salesOrder?.id || initialValues?.marketing_product_id || undefined,
unit_price:
deliveryOrder?.unit_price ?? initialValues?.unit_price ?? undefined,
unit_price: getDisplayedUnitPrice(defaultPricingSource),
total_weight:
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
@@ -186,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,
@@ -224,8 +301,6 @@ const DeliveryOrderProductForm = ({
},
});
const { resetForm } = formik;
const hasWeekField = useMemo(() => {
const marketingType = formik.values.marketing_type?.value?.toLowerCase();
if (marketingType === 'ayam_pullet') {
@@ -245,9 +320,9 @@ const DeliveryOrderProductForm = ({
return false;
}, [formik.values.marketing_product, formik.values.marketing_type]);
const handleResetForm = useCallback(() => {
const handleResetForm = () => {
setFormErrorMessage('');
resetForm({
formik.resetForm({
values: {
delivery_date: '',
vehicle_number: '',
@@ -271,20 +346,17 @@ const DeliveryOrderProductForm = ({
},
});
// setSelectedProduct(null);
}, [resetForm]);
};
const handleBlurField = useCallback(
(field: string) => {
setCurrentInput(field);
const handleBlurField = (field: string) => {
setCurrentInput(field);
handleMarketingCalculation(field, {
values: formik.values,
setFieldValue: formik.setFieldValue,
hasSisaBerat,
});
},
[formik.values, formik.setFieldValue, hasSisaBerat]
);
handleMarketingCalculation(field, {
values: formik.values,
setFieldValue: formik.setFieldValue,
hasSisaBerat,
});
};
// 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 = (
@@ -329,25 +401,25 @@ const DeliveryOrderProductForm = ({
const { setValues: setFormikValues } = formik;
const processedInitialValuesRef = useRef<number | null>(null);
useEffect(() => {
if (initialValues) {
if (processedInitialValuesRef.current === initialValues.id) return;
processedInitialValuesRef.current = initialValues.id as number;
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);
}
}
}
}, [handleResetForm, initialValues, setFormikValues]);
}, [initialValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
@@ -365,10 +437,8 @@ const DeliveryOrderProductForm = ({
);
useEffect(() => {
if (formik.values.week) {
handleBlurField('week');
}
}, [formik.values.week, handleBlurField]);
handleBlurField('week');
}, [formik.values.week]);
return (
<>
@@ -469,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,
});
@@ -483,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={{
@@ -649,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`}
@@ -699,6 +774,9 @@ const DeliveryOrderProductForm = ({
}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/>
)}
@@ -768,12 +846,32 @@ const DeliveryOrderProductForm = ({
/>
)}
{/* Harga per butir untuk TELUR + QTY */}
{/* Harga Satuan */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Butir (Rp)'
label='Harga / Kg (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
@@ -787,27 +885,7 @@ const DeliveryOrderProductForm = ({
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)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
placeholder='Masukan Harga per Kg'
/>
)}
@@ -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!')
@@ -5,9 +5,9 @@ import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
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: '',
@@ -240,18 +305,15 @@ const SalesOrderProductForm = ({
});
};
const handleBlurField = useCallback(
(field: string) => {
setCurrentInput(field);
const handleBlurField = (field: string) => {
setCurrentInput(field);
handleMarketingCalculation(field, {
values: formik.values,
setFieldValue: formik.setFieldValue,
hasSisaBerat,
});
},
[formik.values, formik.setFieldValue, hasSisaBerat]
);
handleMarketingCalculation(field, {
values: formik.values,
setFieldValue: formik.setFieldValue,
hasSisaBerat,
});
};
// 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 = (
@@ -310,10 +372,12 @@ const SalesOrderProductForm = ({
);
useEffect(() => {
if (formik.values.week) {
handleBlurField('week');
}
}, [formik.values.week, handleBlurField]);
handleBlurField('week');
}, [formik.values.week]);
useEffect(() => {
setSelectedProductWarehouse(initialValues?.product_warehouse_data || null);
}, [initialValues?.product_warehouse_data]);
return (
<>
@@ -353,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 */}
@@ -379,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);
@@ -397,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)
@@ -476,7 +541,7 @@ const SalesOrderProductForm = ({
<input
type='radio'
checked={
formik.values.convertion_unit?.value ===
formik.values.convertion_unit?.value.toLowerCase() ===
option.value
}
onChange={() => null}
@@ -499,7 +564,9 @@ const SalesOrderProductForm = ({
} per ${formik.values.convertion_unit?.value}`}
value={formik.values.weight_per_convertion ?? ''}
onChange={(e) => {
const value = Number(e.target.value);
const value = Number(e.target.value)
? Number(e.target.value)
: '';
handleFieldChange('weight_per_convertion', value, () =>
setCurrentInput(e.target.name)
);
@@ -553,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`}
@@ -603,6 +670,9 @@ const SalesOrderProductForm = ({
}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/>
)}
@@ -670,12 +740,34 @@ const SalesOrderProductForm = ({
/>
)}
{/* Harga per butir untuk TELUR + QTY */}
{/* Harga Satuan per Uom Produk Warehouse */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan...'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Butir (Rp)'
label='Harga / Kg (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
@@ -689,29 +781,7 @@ const SalesOrderProductForm = ({
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)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
placeholder='Masukan Harga per Kg'
/>
)}
@@ -5,8 +5,9 @@ import { Icon } from '@iconify/react';
import { useRef, useMemo } from 'react';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import { Marketing, BaseDelivery } from '@/types/api/marketing/marketing';
import { Marketing } from '@/types/api/marketing/marketing';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { DeliveryProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[];
@@ -55,14 +56,17 @@ const DeliveryOrderProductTable = ({
const deliveryItems = useMemo(() => {
if (!hasDeliveryOrder) return [];
return (
marketing?.delivery_order?.flatMap((doItem) =>
doItem.deliveries.map((delivery) => ({
...delivery,
do_number: doItem.do_number,
delivery_date: doItem.delivery_date,
warehouse: doItem.warehouse,
}))
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]);
@@ -81,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') && (
@@ -98,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>
@@ -136,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>
@@ -211,7 +219,7 @@ const DeliveryOrderProductTable = ({
};
const renderDeliveryOrderContent = (
item: BaseDelivery & {
item: DeliveryOrderProductFormValues & {
do_number: string;
delivery_date: string;
warehouse: Warehouse;
@@ -230,25 +238,43 @@ 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 === '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</td>
<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.product_warehouse?.product?.name}
{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(item.qty)} ${item.product_warehouse?.product?.uom?.name ?? ''}`
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
: '-'}
</td>
</tr>
@@ -271,13 +297,13 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(item.unit_price)}
{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(item.total_price)}
{formatCurrency(Number(item.total_price))}
</td>
</tr>
</>
@@ -333,7 +359,9 @@ const DeliveryOrderProductTable = ({
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
{hasDeliveryOrder
? deliveryItems.map((item, index) => (
<div key={`do-table-${item.product_warehouse?.id}-${index}`}>
<div
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
>
{formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table
@@ -349,8 +377,11 @@ const DeliveryOrderProductTable = ({
</div>
) : (
<Card
key={`do-table-${item.product_warehouse?.id}-${index}`}
title={item.product_warehouse?.product?.name || 'Produk'}
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'
@@ -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>
@@ -6,6 +6,7 @@ import { useMemo, useState } from 'react';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
interface DeliveryOrderExportProps {
data?: Marketing;
@@ -19,6 +20,9 @@ const DeliveryOrderExport = ({
}: DeliveryOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data;
const searchParams = useSearchParams();
const action = searchParams.get('action');
const id = searchParams.get('id');
const handleDownloadPDF = async () => {
if (!salesData) {
@@ -49,6 +53,7 @@ const DeliveryOrderExport = ({
toast.error('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
window.location.href = `/marketing?action=${action}&id=${id}`;
}
};
@@ -87,7 +92,7 @@ const PDFDocument = ({
return (
deliveryOrder.deliveries?.reduce((a, b) => a + b.total_price, 0) ?? 0
);
}, [deliveryOrder.deliveries]);
}, []);
return (
<Document>
@@ -96,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>
@@ -6,6 +6,7 @@ import { useMemo, useState } from 'react';
import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
interface SalesOrderExportProps {
data?: Marketing;
@@ -15,6 +16,9 @@ interface SalesOrderExportProps {
const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data;
const searchParams = useSearchParams();
const action = searchParams.get('action');
const id = searchParams.get('id');
const handleDownloadPDF = async () => {
if (!salesData) {
@@ -43,6 +47,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
toast.error('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
window.location.href = `/marketing?action=${action}&id=${id}`;
}
};
@@ -82,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>
@@ -314,6 +314,10 @@ const KandangsTable = () => {
accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC',
},
{
accessorFn: (row) => row.kandang_group?.name ?? '-',
header: 'Kandang Group',
},
{
header: 'Aksi',
cell: (props: CellContext<Kandang, unknown>) => {
@@ -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'>
@@ -154,17 +154,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku,
uom_id: values.uom_id,
product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0,
product_price: parseFloat(values.product_price.toString()) || 0,
selling_price: values.selling_price
? parseInt(values.selling_price.toString()) || 0
? parseFloat(values.selling_price.toString()) || 0
: undefined,
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
tax: values.tax ? parseFloat(values.tax.toString()) || 0 : undefined,
expiry_period: values.expiry_period
? parseInt(values.expiry_period.toString()) || 0
? parseFloat(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0,
price: parseFloat(s.price.toString()) || 0,
})),
flag: values.flag,
sub_flags: values.sub_flags,
@@ -44,9 +44,7 @@ const ChickinFormKandang = ({
const afterSubmitFormChickin = () => {
setOpenChickin(true);
if (afterSubmit) {
afterSubmit();
}
afterSubmit && afterSubmit();
refreshApprovals();
};
@@ -23,7 +23,7 @@ const ChickinLogsView = ({
rawDataApprovals: BaseApproval[];
}) => {
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const { openChickinApproveModal } = useChickinStore();
const { openChickinApproveModal, openChickinDeleteModal } = useChickinStore();
const handleClickApprove = () => {
openChickinApproveModal(initialValues, async (notes?: string) => {
@@ -40,8 +40,21 @@ const ChickinLogsView = ({
toast.error(approveChickinRes?.message as string);
setChickinErrorMessage(approveChickinRes?.message as string);
}
if (afterSubmit) {
afterSubmit();
afterSubmit && afterSubmit();
});
};
const handleDeleteChickin = (chickinId: number) => {
openChickinDeleteModal(chickinId, async () => {
const deleteRes = await ChickinApi.delete(chickinId);
if (isResponseSuccess(deleteRes)) {
toast.success(deleteRes?.message || 'Chickin berhasil dihapus');
afterSubmit && afterSubmit();
}
if (isResponseError(deleteRes)) {
toast.error(deleteRes?.message || 'Gagal menghapus chickin');
}
});
};
@@ -88,14 +101,30 @@ const ChickinLogsView = ({
<div className='text-lg font-semibold'>
Chick In #{index + 1} - {latestApproval?.step_number}
</div>
<PillBadge
content={
isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
}
color={
isApproved ? 'green' : isPending ? 'yellow' : 'gray'
}
/>
<div className='flex flex-row gap-2 items-center'>
<PillBadge
content={
isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
}
color={
isApproved ? 'green' : isPending ? 'yellow' : 'gray'
}
/>
{isApproved && (
<Button
color='error'
className='w-fit text-sm text-base-100 rounded-lg shadow-sm btn-xs!'
onClick={() => handleDeleteChickin(chickin.id)}
>
<Icon
icon='heroicons:trash-solid'
width={10}
height={10}
/>
</Button>
)}
</div>
</div>
{/* Tanggal Chick In */}
@@ -59,8 +59,7 @@ const RowOptionsMenu = ({
detailClickHandler: (id: number) => void;
deleteClickHandler: () => void;
}) => {
// TODO: change this to real condition
const showEditButton = true;
const showEditButton = props.row.original.approval?.step_number !== 2;
const showDeleteButton = showEditButton;
@@ -200,6 +199,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const confirmModal = useModal();
const successModal = useModal();
const chickinApproveModal = useModal();
const chickinDeleteModal = useModal();
const closingModal = useModal();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
@@ -214,6 +214,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
chickinApproveCallback,
closeChickinApproveModal,
setChickinApproveLoading,
isChickinDeleteModalOpen,
isChickinDeleteLoading,
chickinDeleteCallback,
closeChickinDeleteModal,
setChickinDeleteLoading,
} = useChickinStore();
const {
@@ -478,6 +483,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
}
}, [isChickinApproveModalOpen, chickinApproveModal]);
useEffect(() => {
if (isChickinDeleteModalOpen) {
chickinDeleteModal.openModal();
} else {
chickinDeleteModal.closeModal();
}
}, [isChickinDeleteModalOpen, chickinDeleteModal]);
useEffect(() => {
if (isClosingModalOpen) {
closingModal.openModal();
@@ -1208,6 +1221,38 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
}}
/>
{/* Chickin Delete Modal */}
<ConfirmationModal
ref={chickinDeleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data chick in ini?'
secondaryButton={{
text: 'Tidak',
onClick: () => {
closeChickinDeleteModal();
},
}}
className={{
modal: 'z-9999',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isChickinDeleteLoading,
onClick: async () => {
if (chickinDeleteCallback) {
setChickinDeleteLoading(true);
try {
await chickinDeleteCallback();
} finally {
setChickinDeleteLoading(false);
closeChickinDeleteModal();
}
}
},
}}
/>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
@@ -21,6 +21,7 @@ import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import Tooltip from '@/components/Tooltip';
import { useFormik } from 'formik';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
@@ -36,6 +37,7 @@ import {
import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton';
import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -46,6 +48,7 @@ import { useUiStore } from '@/stores/ui/ui.store';
import { usePathname } from 'next/navigation';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -104,20 +107,76 @@ const RowOptionsMenu = ({
return recording.approval?.action === 'REJECTED';
};
const isRecordingEditable = (recording: Recording) => {
const isGrowingCategory =
recording.project_flock?.project_flock_category === 'GROWING';
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
if (isGrowingLockedByLaying) {
return false;
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
const restriction = getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
if (restriction.isLocked) {
return false;
}
return true;
};
const getRecordingRestrictionInfo = (recording: Recording) => {
const isGrowingCategory =
recording.project_flock?.project_flock_category === 'GROWING';
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
if (isGrowingLockedByLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing tidak dapat diubah karena sudah masuk fase laying dan dipakai pada recording laying',
};
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
return getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
};
const isApproved = isRecordingApproved(props.row.original);
const isRejected = isRecordingRejected(props.row.original);
const isEditable = isRecordingEditable(props.row.original);
const restrictionInfo = getRecordingRestrictionInfo(props.row.original);
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
<Tooltip
content={restrictionInfo.isLocked ? restrictionInfo.lockReason : ''}
position='top'
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
className={restrictionInfo.isLocked ? 'text-error' : ''}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
</Tooltip>
<PopoverContent
id={popoverId}
@@ -138,18 +197,20 @@ const RowOptionsMenu = ({
View Details
</Button>
</RequirePermission>
<RequirePermission permissions='lti.production.recording.update'>
<Button
href={`/production/recording/detail/edit/?recordingId=${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>
{isEditable && (
<RequirePermission permissions='lti.production.recording.update'>
<Button
href={`/production/recording/detail/edit/?recordingId=${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>
)}
{!isApproved && !isRejected && (
<RequirePermission permissions='lti.production.recording.approve'>
<Button
@@ -182,20 +243,22 @@ const RowOptionsMenu = ({
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.production.recording.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>
{isEditable && (
<RequirePermission permissions='lti.production.recording.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>
@@ -290,6 +353,9 @@ const RecordingTable = () => {
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const singleDeleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
@@ -545,12 +611,17 @@ const RecordingTable = () => {
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
await RecordingApi.delete(selectedRecording?.id as number);
refreshRecordings();
const response = await RecordingApi.delete(selectedRecording?.id as number);
singleDeleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Recording!');
refreshRecordings();
} else {
toast.error(response?.message || 'Failed to delete Recording');
}
};
const approveHandler = async (notes: string) => {
@@ -619,6 +690,14 @@ const RecordingTable = () => {
});
}, [selectedRowIds, recordings, isRecordingApproved]);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
await RecordingApi.exportToExcel(getTableFilterQueryString());
setIsLoadingExportingToExcel(false);
};
useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) {
const newSelection: Record<string, boolean> = {};
@@ -746,11 +825,30 @@ const RecordingTable = () => {
{
header: 'Kategori',
cell: (props) => {
const isTransition = props.row.original.is_transition;
const category =
props.row.original.project_flock?.project_flock_category;
if (!category) return '-';
props.row.original.project_flock?.project_flock_category ||
'GROWING';
const color = category === 'LAYING' ? 'info' : 'warning';
return <StatusBadge color={color} text={formatTitleCase(category)} />;
const isGrowingLocked =
category === 'GROWING' && props.row.original.is_laying;
return (
<div className='flex flex-col gap-1'>
<StatusBadge color={color} text={formatTitleCase(category)} />
{isTransition && (
<span className='text-xs text-warning font-medium'>
(Transisi)
</span>
)}
{isGrowingLocked && (
<span className='text-xs text-error font-medium'>
(Penguncian)
</span>
)}
</div>
);
},
},
{
@@ -1227,6 +1325,50 @@ const RecordingTable = () => {
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
trigger={
<Button
variant='outline'
color='none'
className={cn(
'px-3 py-2.5 rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon
width={20}
height={20}
icon='heroicons:cloud-arrow-down'
/>
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon
width={14}
height={14}
icon='heroicons:chevron-down'
/>
</div>
</Button>
}
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcelHandler}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
</Dropdown>
</div>
</div>
@@ -34,6 +34,7 @@ type RecordingGrowingFormSchemaType = {
}[];
depletions: {
product_warehouse_id?: number;
source_product_warehouse_id?: number;
qty?: number | string;
}[];
};
@@ -53,6 +54,7 @@ export type StockSchema = {
export type DepletionSchema = {
product_warehouse_id?: number;
source_product_warehouse_id?: number;
qty?: number | string;
};
@@ -69,7 +71,7 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
.typeError('Produk harus berupa angka!'),
qty: Yup.number()
.required('Jumlah penggunaan wajib diisi!')
.min(1, 'Jumlah penggunaan tidak boleh 0!')
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
.typeError('Jumlah penggunaan harus berupa angka!'),
});
@@ -77,6 +79,9 @@ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number()
.optional()
.typeError('Depletions harus berupa angka!'),
source_product_warehouse_id: Yup.number()
.optional()
.typeError('Gudang sumber harus berupa angka!'),
qty: Yup.number()
.optional()
.typeError('Jumlah depletions harus berupa angka!'),
@@ -259,6 +264,7 @@ export const getRecordingGrowingFormInitialValues = (
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
) => ({
product_warehouse_id: depletion.product_warehouse_id,
source_product_warehouse_id: depletion.source_product_warehouse_id,
qty: depletion.qty,
})
) ?? [
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
export type RecordingRestriction = {
canEditStock: boolean;
canEditDepletion: boolean;
canEditEgg: boolean;
isLocked: boolean;
lockReason?: string;
};
export const getRecordingRestriction = (
isLaying: boolean,
isTransition: boolean,
currentIsLaying?: boolean
): RecordingRestriction => {
if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true;
if (isLayingKandangInTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
} else {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
}
if (!isLaying && !isTransition && currentIsLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
if (!isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
}
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
};
};
@@ -50,12 +50,18 @@ const TransferToLayingConfirmationModalTable = ({
transferToLayingForm?: TransferToLayingFormValues;
transferToLayingId?: number;
}) => {
const isValidId =
transferToLayingId !== undefined &&
transferToLayingId !== null &&
!isNaN(transferToLayingId) &&
transferToLayingId > 0;
const { data: transferToLaying, isLoading: isLoadingTransferToLaying } =
useSWR(
transferToLayingId
isValidId
? ['detail-transfer-to-laying', String(transferToLayingId)]
: undefined,
([id]) => TransferToLayingApi.getSingle(Number(id))
([, id]) => TransferToLayingApi.getSingle(Number(id))
);
const confirmationTableColumns: ColumnDef<TransferToLayingConfirmationTableDataType>[] =
@@ -273,12 +279,16 @@ const TransferToLayingConfirmationModal = ({
{transferToLayingIds &&
!transferToLayingForm &&
transferToLayingIds.map((transferToLayingId, idx) => (
<TransferToLayingConfirmationModalTable
key={idx}
transferToLayingId={transferToLayingId}
/>
))}
transferToLayingIds
.filter(
(id) => id !== undefined && id !== null && !isNaN(id) && id > 0
)
.map((transferToLayingId, idx) => (
<TransferToLayingConfirmationModalTable
key={idx}
transferToLayingId={transferToLayingId}
/>
))}
{withNote && (
<TextArea
@@ -82,7 +82,7 @@ const TransferToLayingDetailModal = () => {
if (modalAction === 'detail') {
detailModal.openModal();
}
}, [modalAction, detailModal]);
}, [modalAction]);
return (
<Modal
@@ -229,6 +229,8 @@ const TransferToLayingFormModal = () => {
ProjectFlock | undefined
>(undefined);
const [maxSourceQuantity, setMaxSourceQuantity] = useState<number>(0);
const selectedFlockDestinationRawData = isResponseSuccess(
flockDestinationRawData
)
@@ -353,19 +355,14 @@ const TransferToLayingFormModal = () => {
return { available: countAvailable, unavailable: countUnavailable };
}, [mappedFlockDestinationKandangsMaxTargetQty]);
const totalEnteredChickenForTransfer =
formik.values.flockSourceKandangs.reduce(
(acc, item) => acc + Number(item.quantity),
0
);
const totalTransferedChicken = formik.values.flockDestinationKandangs.reduce(
(acc, item) => acc + Number(item.quantity),
0
);
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
const totalAvailableChickenForTransfer =
totalEnteredChickenForTransfer - totalTransferedChicken;
maxSourceQuantity - totalTransferedChicken;
const isNextButtonDisabled = useMemo(() => {
if (step === 1) {
@@ -397,6 +394,7 @@ const TransferToLayingFormModal = () => {
formik.setFieldValue('maxTotalQuantity', '');
formik.setFieldValue('reason', '');
formik.setFieldTouched('reason', false);
setMaxSourceQuantity(0);
setStep(2);
};
@@ -404,6 +402,7 @@ const TransferToLayingFormModal = () => {
const flockSourceChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('flockSource', val);
formik.setFieldValue('flockSourceKandangs', []);
setMaxSourceQuantity(0);
};
const flockDestinationChangeHandler = (
@@ -469,6 +468,26 @@ const TransferToLayingFormModal = () => {
formik.setFieldValue('maxTotalQuantity', totalTransferedChicken);
}, [totalTransferedChicken, formik.values.flockDestinationKandangs]);
// Auto-fill source kandang quantity from total destination quantity
useEffect(() => {
if (formik.values.flockSourceKandangs.length > 0) {
formik.setFieldValue(
'flockSourceKandangs.0.quantity',
totalTransferedChicken
);
}
}, [totalTransferedChicken]);
useEffect(() => {
if (
formik.values.flockSourceKandangs.length > 0 &&
formik.values.flockSourceKandangs[0].maxQuantity &&
maxSourceQuantity === 0
) {
setMaxSourceQuantity(formik.values.flockSourceKandangs[0].maxQuantity);
}
}, [formik.values.flockSourceKandangs, maxSourceQuantity]);
return (
<>
<Modal
@@ -583,14 +602,9 @@ const TransferToLayingFormModal = () => {
k.kandang.value === item.project_flock_kandang_id
);
const flockSourceKandangCheckboxChangeHandler: FormEventHandler<
HTMLInputElement
> = (e) => {
const checked = (e.target as HTMLInputElement)
.checked;
if (checked) {
const flockSourceKandangRadioChangeHandler = () => {
if (isAvailable) {
formik.setFieldValue('flockSourceKandangs', [
...formik.values.flockSourceKandangs,
{
kandang: {
value: item.project_flock_kandang_id,
@@ -600,15 +614,7 @@ const TransferToLayingFormModal = () => {
maxQuantity: item.available_qty,
},
]);
} else {
formik.setFieldValue(
'flockSourceKandangs',
formik.values.flockSourceKandangs.filter(
(k) =>
k.kandang.value !==
item.project_flock_kandang_id
)
);
setMaxSourceQuantity(item.available_qty);
}
};
@@ -618,28 +624,22 @@ const TransferToLayingFormModal = () => {
className='w-full p-3 flex flex-row items-center justify-between'
>
<div className='flex flex-row items-center gap-3'>
<CheckboxInput
name={`flockSourceKandang.${itemIdx}.value`}
<input
type='radio'
name='flockSourceKandang'
value={item.project_flock_kandang_id}
checked={isChecked}
onChange={
flockSourceKandangCheckboxChangeHandler
}
size='md'
onChange={flockSourceKandangRadioChangeHandler}
disabled={!isAvailable}
classNames={{
checkbox: cn({
'bg-base-200 border border-base-content/10 opacity-100':
!isAvailable,
}),
}}
className={cn('radio radio-md radio-primary', {
'opacity-50 cursor-not-allowed': !isAvailable,
})}
/>
<label
htmlFor={`flockSourceKandang.${itemIdx}.value`}
className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable,
'cursor-not-allowed': !isAvailable,
'cursor-not-allowed opacity-50': !isAvailable,
})}
>
{item.kandang_name}{' '}
@@ -858,7 +858,7 @@ const TransferToLayingFormModal = () => {
<NumberInput
key={`flockSourceKandangs-${item.kandang.value}-${index}`}
name={`flockSourceKandangs.${index}.quantity`}
placeholder='Masukkan Kuantitas'
placeholder='Masukkan Kuantitas pada Kandang Tujuan'
value={item.quantity}
onChange={formik.handleChange}
isError={isInvalid}
@@ -875,6 +875,8 @@ const TransferToLayingFormModal = () => {
<div className='w-px bg-base-content/10' />
</div>
}
readOnly
disabled
className={{
inputPrefix:
'py-0 px-0 pl-3 text-base-content/50 bg-transparent border-r-0',
@@ -1000,7 +1002,7 @@ const TransferToLayingFormModal = () => {
isError={totalAvailableChickenForTransfer < 0}
errorMessage={
totalAvailableChickenForTransfer < 0
? `Jumlah transfer melebihi ketersediaan (${formatNumber(totalEnteredChickenForTransfer, 'en-US')} ayam)`
? `Jumlah transfer melebihi ketersediaan (${formatNumber(maxSourceQuantity, 'en-US')} ayam)`
: ''
}
disabled
@@ -48,11 +48,11 @@ const RowOptionsMenu = ({
popoverPosition: 'bottom' | 'top';
deleteClickHandler: () => void;
}) => {
const showEditButton =
props.row.original.approval.action !== 'APPROVED' &&
props.row.original.approval.action !== 'REJECTED';
const showEditButton = props.row.original.approval.action !== 'APPROVED';
const showDeleteButton = showEditButton;
const showDeleteButton =
props.row.original.approval.action === 'APPROVED' ||
props.row.original.approval.step_name.toLowerCase() === 'pengajuan';
const popoverId = `transferToLaying#${props.row.original.id}`;
const popoverAnchorName = `--anchor-transferToLaying#${props.row.original.id}`;
@@ -463,7 +463,7 @@ const TransferToLayingsTable = () => {
updateFilter('filter_by', '');
updateFilter('sort_by', '');
}
}, [sorting, updateFilter]);
}, [sorting]);
return (
<>
@@ -60,6 +60,25 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
router.push(`/production/uniformity?action=reject&id=${initialValues.id}`);
};
const handleViewUniformityDetails = () => {
if (!uniformity_details || uniformity_details.length === 0) {
setShouldFetchDetails(true);
return;
}
setExpandedDrawerContent(
<UniformityDetailsPreview
info_umum={initialValues.info_umum}
uniformity_details={uniformity_details}
uniformityId={initialValues.id}
/>
);
setTimeout(() => {
setExpandedDrawerOpen(true);
}, 0);
};
useEffect(() => {
if (
shouldFetchDetails &&
@@ -183,25 +202,6 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
}
if (id === 'document-name') {
const handleViewUniformityDetails = () => {
if (!uniformity_details || uniformity_details.length === 0) {
setShouldFetchDetails(true);
return;
}
setExpandedDrawerContent(
<UniformityDetailsPreview
info_umum={initialValues.info_umum}
uniformity_details={uniformity_details}
uniformityId={initialValues.id}
/>
);
setTimeout(() => {
setExpandedDrawerOpen(true);
}, 0);
};
return (
<div className='flex items-center gap-2'>
<span>{valueMap[id]}</span>
@@ -231,14 +231,7 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
},
},
],
[
initialValues,
isLoading,
uniformity_details,
setShouldFetchDetails,
setExpandedDrawerContent,
setExpandedDrawerOpen,
]
[initialValues, handleViewUniformityDetails, isLoading]
);
const samplingTableData: DetailOptionType[] = useMemo(() => {
@@ -3,7 +3,6 @@ import { Uniformity } from '@/types/api/production/uniformity';
type UniformityFormSchemaType = {
date: string;
week: number;
location?: {
value: number;
label: string;
@@ -45,10 +44,6 @@ const FileSchema = Yup.mixed<File>()
export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> =
Yup.object({
date: Yup.string().required('Tanggal wajib diisi!'),
week: Yup.number()
.min(1, 'Minggu ke wajib diisi!')
.required('Minggu ke wajib diisi!')
.typeError('Minggu ke wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
@@ -81,7 +76,6 @@ export type UniformityFormValues = Yup.InferType<typeof UniformityFormSchema>;
export type UniformityFormData = {
date: string;
week: number;
project_flock_kandang_id: number;
document: File | null;
document_name: string;
@@ -91,8 +85,7 @@ export const getUniformityFormInitialValues = (
initialValues?: Partial<Uniformity>
): UniformityFormValues => {
return {
date: initialValues?.week ? '' : '',
week: initialValues?.week ?? 0,
date: '',
location: null,
location_id: 0,
project_flock: null,
@@ -27,7 +27,6 @@ import { LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
RecordingApi,
} from '@/services/api/production';
import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -40,7 +39,6 @@ import {
ProjectFlockKandangLookup,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { Recording } from '@/types/api/production/recording';
import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -204,23 +202,6 @@ const UniformityForm = ({
? projectFlockKandangLookupData.data
: undefined;
// ===== RECORDINGS DATA (FOR WEEK CALCULATION) =====
const recordingsUrl = useMemo(() => {
if (!projectFlockKandangLookup?.project_flock_kandang_id) return null;
const params = new URLSearchParams({
page: '1',
limit: '100',
project_flock_kandang_id:
projectFlockKandangLookup.project_flock_kandang_id.toString(),
});
return `${RecordingApi.basePath}?${params.toString()}`;
}, [projectFlockKandangLookup?.project_flock_kandang_id]);
const { data: recordingsData } = useSWR(
recordingsUrl,
recordingsUrl ? RecordingApi.getAllFetcher : null
);
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<UniformityFormValues>(
() => getUniformityFormInitialValues(initialValues),
@@ -246,7 +227,6 @@ const UniformityForm = ({
setUniformityFormData({
date: values.date,
week: values.week,
project_flock_kandang_id: projectFlockKandangId,
document: values.document as File,
document_name: (values.document as File).name,
@@ -475,59 +455,6 @@ const UniformityForm = ({
generateUniformityTemplate(population, projectFlockKandangLookup);
}, [projectFlockKandangLookup]);
// ===== SIDE EFFECTS =====
useEffect(() => {
if (
projectFlockKandangLookup?.chick_in_date &&
projectFlockKandangLookup?.project_flock_kandang_id
) {
const chickInDate = new Date(projectFlockKandangLookup.chick_in_date);
chickInDate.setHours(0, 0, 0, 0);
let initialWeek = 18;
if (
isResponseSuccess(recordingsData) &&
recordingsData.data &&
recordingsData.data.length > 0
) {
const sortedRecordings = [...recordingsData.data].sort(
(a: Recording, b: Recording) =>
new Date(a.record_datetime).getTime() -
new Date(b.record_datetime).getTime()
);
const earliestRecording = sortedRecordings[0];
if (earliestRecording?.project_flock?.production_standart?.week) {
initialWeek =
earliestRecording.project_flock.production_standart.week;
}
}
if (formik.values.date) {
const selectedDate = new Date(formik.values.date);
selectedDate.setHours(0, 0, 0, 0);
const daysDiff = Math.floor(
(selectedDate.getTime() - chickInDate.getTime()) /
(1000 * 60 * 60 * 24)
);
const weeksDiff = Math.floor(daysDiff / 7);
setFieldValue('week', initialWeek + weeksDiff);
} else {
setFieldValue('week', initialWeek);
}
}
}, [
projectFlockKandangLookup?.chick_in_date,
projectFlockKandangLookup?.project_flock_kandang_id,
recordingsData,
formik.values.date,
setFieldValue,
]);
useEffect(() => {
const unsub = subscribeValidate(() => {
setIsValid(true);
@@ -597,6 +524,7 @@ const UniformityForm = ({
onBlur={formik.handleBlur}
isError={formik.touched.date && Boolean(formik.errors.date)}
errorMessage={formik.errors.date as string}
disabled={isNextStep}
/>
<SelectInput
@@ -615,6 +543,7 @@ const UniformityForm = ({
errorMessage={formik.errors.location_id as string}
isClearable
className={{ wrapper: 'w-full' }}
isDisabled={isNextStep}
/>
<SelectInput
@@ -627,7 +556,7 @@ const UniformityForm = ({
onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks}
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id}
isDisabled={!formik.values.location_id || isNextStep}
isError={
formik.touched.project_flock_id &&
Boolean(formik.errors.project_flock_id)
@@ -644,7 +573,7 @@ const UniformityForm = ({
value={formik.values.kandang}
onChange={handleKandangChange}
options={kandangOptions}
isDisabled={!formik.values.project_flock_id}
isDisabled={!formik.values.project_flock_id || isNextStep}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
@@ -63,7 +63,6 @@ const UniformityResultForm = () => {
try {
const payload = {
date: uniformityFormData.date,
week: uniformityFormData.week,
project_flock_kandang_id: uniformityFormData.project_flock_kandang_id,
document: uniformityFormData.document,
};
@@ -0,0 +1,201 @@
'use client';
import { RefObject, useState, useEffect } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { PurchaseFilter } from '@/types/api/purchase/purchase';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: PurchaseFilter) => void;
onReset?: () => void;
}
const PurchaseFilterModal = ({
ref,
onSubmit,
onReset,
}: PurchaseFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
// ===== CLEANUP TOAST WHEN MODAL CLOSES =====
useEffect(() => {
const dialogElement = ref.current;
const handleModalClose = () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
dialogElement?.addEventListener('close', handleModalClose);
return () => {
dialogElement?.removeEventListener('close', handleModalClose);
};
}, [ref, dateErrorShown]);
const {
setInputValue: setProductCategoryInputValue,
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategoryOptions,
loadMore: loadMoreProductCategory,
} = useSelect<ProductCategory>(
ProductCategoryApi.basePath,
'id',
'name',
'search'
);
const formik = useFormik<{
poDate: string;
category: { label: string; value: number }[];
status: { label: string; value: string }[];
}>({
initialValues: {
poDate: '',
category: [],
status: [],
},
onSubmit: async (values) => {
const formattedValues = {
...values,
category: values.category.map((item) => String(item.value)),
status: values.status.map((item) => String(item.value)),
};
onSubmit?.(formattedValues);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
const productCategoryChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('category', val);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl',
}}
>
<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-gray-300'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<DateInput
label='PO Date'
name='poDate'
placeholder='Pilih Tanggal'
value={formik.values.poDate}
onChange={formik.handleChange}
isNestedModal
/>
<SelectInputCheckbox
label='Kategori'
placeholder='Pilih Kategori'
value={formik.values.category}
onChange={productCategoryChangeHandler}
options={productCategoryOptions}
isLoading={isLoadingProductCategoryOptions}
onInputChange={setProductCategoryInputValue}
onMenuScrollToBottom={loadMoreProductCategory}
/>
<SelectInputCheckbox
label='Status'
placeholder='Status'
value={formik.values.status}
onChange={statusChangeHandler}
options={PURCHASE_ORDER_APPROVAL_LINE.map((item) => ({
label: item.step_name,
value: item.step_name,
}))}
/>
</div>
</div>
{/* Modal Footer */}
<div className='p-4 flex justify-between gap-4 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='ghost'
color='none'
className='p-3 rounded-lg text-base-content/65'
>
Reset Filter
</Button>
<Button
type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default PurchaseFilterModal;
@@ -14,6 +14,7 @@ import useSWRInfinite from 'swr/infinite';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
@@ -25,18 +26,19 @@ import PopoverContent from '@/components/popover/PopoverContent';
import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter';
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
import { cn, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Purchase } from '@/types/api/purchase/purchase';
import { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme';
import Link from 'next/link';
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -165,14 +167,21 @@ const PurchaseTable = () => {
} = useTableFilter({
initial: {
search: '',
po_date: '',
approval_status: '',
product_category_id: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
po_date: 'po_date',
approval_status: 'approval_status',
product_category_id: 'product_category_id',
},
});
// ===== MODAL HOOKS =====
const filterModal = useModal();
const deleteModal = useModal();
// ===== API DATA FETCHING =====
@@ -410,13 +419,17 @@ const PurchaseTable = () => {
[updateFilter, setSearchValue]
);
// const pageSizeChangeHandler = useCallback(
// (val: OptionType | OptionType[] | null) => {
// const newVal = val as OptionType;
// setPageSize(newVal.value as number);
// },
// [setPageSize]
// );
const filterSubmitHandler = (values: PurchaseFilter) => {
updateFilter('po_date', values.poDate);
updateFilter('product_category_id', values.category.join(','));
updateFilter('approval_status', values.status.join(','));
};
const filterResetHandler = () => {
updateFilter('po_date', '');
updateFilter('product_category_id', '');
updateFilter('approval_status', '');
};
return (
<>
@@ -455,6 +468,20 @@ const PurchaseTable = () => {
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'filter_by',
'sort_by',
]}
fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
</div>
</div>
@@ -513,6 +540,13 @@ const PurchaseTable = () => {
</div>
{/* ===== MODAL COMPONENTS ===== */}
<PurchaseFilterModal
ref={filterModal.ref}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
@@ -294,7 +294,6 @@ const PurchaseOrderAcceptApprovalForm = ({
item.expedition_vendor_id || item.expedition_vendor?.id || null;
return {
purchase_item: null,
purchase_item_id: item.id,
received_date: item.received_date
? new Date(item.received_date).toISOString().split('T')[0]
@@ -308,7 +307,7 @@ const PurchaseOrderAcceptApprovalForm = ({
}
: null,
expedition_vendor_id: expeditionVendorId,
received_qty: item.total_qty || '',
received_qty: item.sub_qty || '',
transport_per_item: item.transport_per_item || '',
};
});
@@ -367,6 +366,9 @@ const PurchaseOrderAcceptApprovalForm = ({
);
} else {
formik.setFieldValue(`items.${idx}.expedition_vendor_id`, null);
formik.setFieldValue(`items.${idx}.transport_per_item`, null);
formik.setFieldValue(`items.${idx}.vehicle_number`, null);
}
};
@@ -553,6 +555,7 @@ const PurchaseOrderAcceptApprovalForm = ({
)
}
onBlur={formik.handleBlur}
disabled={!Boolean(formItem?.expedition_vendor)}
isError={
isRepeaterInputError(idx, 'vehicle_number').isError
}
@@ -569,7 +572,7 @@ const PurchaseOrderAcceptApprovalForm = ({
<td>
<SelectInput
isClearable={true}
value={formItem?.expedition_vendor}
value={formItem?.expedition_vendor ?? null}
key={`expedition-vendor-${idx}`}
onChange={(val) =>
expeditionVendorChangeHandler(idx, val)
@@ -657,6 +660,7 @@ const PurchaseOrderAcceptApprovalForm = ({
thousandSeparator=','
decimalSeparator='.'
inputPrefix={'Rp'}
disabled={!Boolean(formItem?.expedition_vendor)}
isError={
isRepeaterInputError(idx, 'transport_per_item')
.isError
@@ -31,10 +31,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null;
items: {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
@@ -68,10 +64,6 @@ export type PurchaseStaffApprovalItemSchema = {
};
export type PurchaseAcceptApprovalItemSchema = {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
@@ -160,12 +152,6 @@ const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManag
const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApprovalItemSchema> =
Yup.object({
purchase_item: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
purchase_item_id: Yup.number()
.min(1, 'Purchase item is required!')
.required('Purchase item is required!')
@@ -185,12 +171,17 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.typeError('No. Surat jalan wajib diisi!'),
vehicle_number: Yup.string()
.nullable()
.optional()
.when('expedition_vendor_id', {
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
then: (schema) => schema.required('Nomor kendaraan wajib diisi!'),
otherwise: (schema) => schema.optional(),
})
.typeError('Nomor kendaraan harus berupa plat nomor!'),
expedition_vendor: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.default(undefined)
.nullable()
.optional(),
expedition_vendor_id: Yup.number()
@@ -213,7 +204,12 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.typeError('Jumlah diterima harus berupa angka!'),
transport_per_item: Yup.mixed<string | number>()
.nullable()
.optional()
.when('expedition_vendor_id', {
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
then: (schema) =>
schema.required('Biaya transport per item wajib diisi!'),
otherwise: (schema) => schema.optional(),
})
.test(
'is-valid-transport-per-item',
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
@@ -34,7 +34,7 @@ const pdfStyles = StyleSheet.create({
marginBottom: 20,
},
logo: {
width: 120,
width: 30,
height: 30,
marginBottom: 8,
},
@@ -265,7 +265,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
<View style={pdfStyles.header}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image
src={'https://placehold.co/120x30/png'}
src='/assets/img/lti-logo.png'
style={pdfStyles.logo}
id={'mbu-logo'}
/>
@@ -273,8 +273,8 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
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>
@@ -47,7 +47,7 @@ export const generateReportExpensePDF = async (
doc.setFontSize(7);
doc.setTextColor(102, 102, 102);
doc.text(
'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',
marginX,
25
);
@@ -33,18 +33,18 @@ import { generateReportExpensePDF } from '../export/ReportExpenseExportPDF';
import { generateReportExpenseExcel } from '../export/ReportExpenseExportXLSX';
import toast from 'react-hot-toast';
import {
KandangApi,
LocationApi,
NonstockApi,
SupplierApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { ProjectFlockKandangApi } from '@/services/api/production/project-flock-kandang';
interface ReportExpenseTabProps {
tabId: string;
@@ -67,7 +67,6 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
const [filterParams, setFilterParams] = useState<FilterParams>({});
// ===== PAGINATION STATE =====
@@ -117,12 +116,10 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
: undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setPage(1);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
setPage(1);
filterModal.closeModal();
},
@@ -139,7 +136,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect<Kandang>(LocationApi.basePath, 'id', 'name', 'search');
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setSupplierInputValue,
@@ -149,14 +146,14 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(
KandangApi.basePath,
setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangs,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'name',
'name_with_period',
'search',
formik.values.location_id?.value
? { location_id: String(formik.values.location_id.value) }
@@ -194,27 +191,25 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
// ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR(
isSubmitted
? () => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category)
params.append('category', filterParams.category);
params.append('page', String(page));
params.append('limit', String(pageSize));
() => {
const params = new URLSearchParams();
if (filterParams.location_id)
params.append('location_id', filterParams.location_id);
if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('project_flock_kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category)
params.append('category', filterParams.category);
params.append('page', String(page));
params.append('limit', String(pageSize));
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
}
: null,
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
},
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
);
@@ -529,25 +524,13 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<ReportExpenseSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !data || data.length === 0 ? (
)}
{!isLoading && (!data || data.length === 0) && (
<ReportExpenseSkeleton
columns={columns}
icon={
@@ -561,7 +544,9 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
)}
{!isLoading && data.length > 0 && (
<>
<Table
data={data}
@@ -658,14 +643,14 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={kandangOptions}
isLoading={isLoadingKandangs}
options={projectFlockKandangOptions}
isLoading={isLoadingProjectFlockKandangs}
value={kandangValue}
onChange={(val) => {
formik.setFieldValue('kandang_id', val);
}}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable
isDisabled={!formik.values.location_id}
className={{ wrapper: 'w-full' }}
@@ -61,7 +61,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
const [pageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
const [filterParams, setFilterParams] = useState<FilterParams>({});
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
@@ -102,13 +101,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
filter_by: values.filter_by || undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setCurrentPage(1);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
setCurrentPage(1);
setHasDateError(false);
if (dateErrorShown) {
@@ -218,23 +215,21 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
() => {
const params = {
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
return ['customer-payment-report', params];
}
: null,
return ['customer-payment-report', params];
},
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_ids,
@@ -700,25 +695,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<CustomerSupplierSkeleton
columns={getTableColumns({} as CustomerPaymentSummary)}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
)}
{!isLoading && data.length === 0 && (
<CustomerSupplierSkeleton
columns={getTableColumns({} as CustomerPaymentSummary)}
icon={
@@ -732,7 +715,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
)}
{!isLoading &&
data.length > 0 &&
data.map((customerReport) => {
const summary = customerReport.summary || {
total_qty: 0,
@@ -761,7 +747,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
}}
variant='bordered'
collapsible={true}
defaultCollapsed={true}
>
<Table
data={[
@@ -825,8 +810,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
/>
</Card>
);
})
)}
})}
</div>
{/* Filter Modal */}
@@ -85,7 +85,6 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
supplier_ids: undefined,
filter_by: undefined,
});
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
@@ -129,7 +128,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
// setIsSubmitted(true);
},
onReset: () => {
setFilterParams({
@@ -138,7 +137,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
supplier_ids: undefined,
filter_by: undefined,
});
setIsSubmitted(false);
// setIsSubmitted(false);
filterModal.closeModal();
},
});
@@ -150,18 +149,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== DATA FETCHING =====
const { data: debtSupplier, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
supplier_ids: filterParams.supplier_ids,
filter_by: filterParams.filter_by,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
};
() => {
const params = {
supplier_ids: filterParams.supplier_ids,
filter_by: filterParams.filter_by,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
};
return ['debt-supplier-report', params];
}
: null,
return ['debt-supplier-report', params];
},
([, params]) =>
DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
@@ -611,25 +608,13 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<DebtSupplierSkeleton
columns={getTableColumns()}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
)}
{!isLoading && data.length === 0 && (
<DebtSupplierSkeleton
columns={getTableColumns()}
icon={
@@ -643,7 +628,10 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
)}
{!isLoading &&
data.length > 0 &&
data.map((supplierReport) => {
return (
<Card
@@ -658,7 +646,6 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
}}
variant='bordered'
collapsible={true}
defaultCollapsed={true}
>
<Table
data={[
@@ -729,8 +716,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
/>
</Card>
);
})
)}
})}
</div>
{/* Filter Modal */}
@@ -61,7 +61,6 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
const [isSubmitted, setIsSubmitted] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
@@ -70,24 +69,34 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
const filterModal = useModal();
// ===== OPTIONS =====
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const {
options: areaOptions,
isLoadingOptions: isLoadingAreas,
setInputValue: setAreaInputValue,
loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } =
useSelect(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'SAPRONAK',
});
const {
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
setInputValue: setSupplierInputValue,
loadMore: loadMoreSupplier,
} = useSelect(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'SAPRONAK',
});
const { options: productOptions, isLoadingOptions: isLoadingProducts } =
useSelect(ProductApi.basePath, 'id', 'name', 'search');
const {
options: productOptions,
isLoadingOptions: isLoadingProducts,
setInputValue: setProductInputValue,
loadMore: loadMoreProduct,
} = useSelect(ProductApi.basePath, 'id', 'name', 'search');
const {
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategories,
setInputValue: setProductCategoryInputValue,
loadMore: loadMoreProductCategory,
} = useSelect(ProductCategoryApi.basePath, 'id', 'name', 'search');
const dataTypeOptions = useMemo(
@@ -131,13 +140,11 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
sort_by: values.sort_by || undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setCurrentPage(1);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
setCurrentPage(1);
setHasDateError(false);
if (dateErrorShown) {
@@ -261,24 +268,22 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
// ===== DATA FETCHING =====
const { data: purchasePerSupplier, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
area_id: filterParams.area_id,
supplier_id: filterParams.supplier_id,
product_id: filterParams.product_id,
product_category_id: filterParams.product_category_id,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
sort_by: filterParams.sort_by,
filter_by: filterParams.filter_by,
page: currentPage,
limit: pageSize,
};
() => {
const params = {
area_id: filterParams.area_id,
supplier_id: filterParams.supplier_id,
product_id: filterParams.product_id,
product_category_id: filterParams.product_category_id,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
sort_by: filterParams.sort_by,
filter_by: filterParams.filter_by,
page: currentPage,
limit: pageSize,
};
return ['logistic-purchase-report', params];
}
: null,
return ['logistic-purchase-report', params];
},
([, params]) =>
LogisticApi.getLogisticPurchasePerSupplierReport(
params.area_id,
@@ -726,21 +731,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<PurchasePerSupplierSkeleton
columns={getTableColumns({} as LogisticPurchasePerSupplierSummary)}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
{isLoading && (
<PurchasePerSupplierSkeleton
columns={getTableColumns({} as LogisticPurchasePerSupplierSummary)}
icon={
@@ -754,7 +745,9 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
title='Memuat Data Pembelian Per Supplier'
subtitle='Silakan tunggu sebentar...'
/>
) : data.length === 0 ? (
)}
{!isLoading && data.length === 0 && (
<PurchasePerSupplierSkeleton
columns={getTableColumns({} as LogisticPurchasePerSupplierSummary)}
icon={
@@ -768,7 +761,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
)}
{!isLoading &&
data.length > 0 &&
data.map((supplierReport) => {
const summary = supplierReport.summary || {
total_qty: 0,
@@ -798,7 +794,6 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
}}
variant='bordered'
collapsible={true}
defaultCollapsed={true}
>
<Table
data={supplierReport.rows}
@@ -827,8 +822,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
/>
</Card>
);
})
)}
})}
</div>
{/* Filter Modal */}
@@ -907,6 +901,8 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
isLoading={isLoadingAreas}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreArea}
/>
{/* Supplier Filter */}
@@ -926,6 +922,8 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
isLoading={isLoadingSuppliers}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSupplier}
/>
{/* Product Filter */}
@@ -945,6 +943,8 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
isLoading={isLoadingProducts}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setProductInputValue}
onMenuScrollToBottom={loadMoreProduct}
/>
{/* Product Category Filter */}
@@ -964,6 +964,8 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
isLoading={isLoadingProductCategories}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setProductCategoryInputValue}
onMenuScrollToBottom={loadMoreProductCategory}
/>
{/* Filter By Type */}
@@ -81,7 +81,7 @@ const getTableColumns = (
},
{
key: 'warehouse',
header: 'Gudang',
header: 'Gudang Fisik',
flex: 1.2,
align: 'left',
cell: ({ row }) => row.warehouse?.name ?? '-',
@@ -30,7 +30,7 @@ export const generateDailyMarketingExcel = async (
{ header: 'Tanggal Jual', key: 'soDate', width: 15 },
{ header: 'Tanggal Realisasi', key: 'realizationDate', width: 18 },
{ header: 'Aging', key: 'aging', width: 10 },
{ header: 'Gudang', key: 'warehouse', width: 25 },
{ header: 'Gudang Fisik', key: 'warehouse', width: 25 },
{ header: 'Pelanggan', key: 'customer', width: 25 },
{ header: 'No. DO', key: 'doNumber', width: 15 },
{ header: 'Sales/Marketing', key: 'sales', width: 20 },
@@ -97,7 +97,7 @@ export const generateDailyMarketingExcel = async (
});
}
worksheet.columns.forEach((column) => {
worksheet.columns.forEach((column: { width?: number }) => {
if (column.width && column.width < 10) {
column.width = 10;
}
@@ -70,9 +70,6 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== SEARCH STATE =====
const [searchValue, setSearchValue] = useState<string>('');
@@ -88,21 +85,33 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
const filterModal = useModal();
// ===== OPTIONS =====
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const {
options: areaOptions,
isLoadingOptions: isLoadingAreas,
setInputValue: setAreaInputValue,
loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', 'search');
const {
options: locationOptions,
isLoadingOptions: isLoadingLocations,
setInputValue: setLocationInputValue,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: warehouseOptions, isLoadingOptions: isLoadingWarehouses } =
useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
const {
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouses,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouse,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomers,
setInputValue: setCustomerInputValue,
loadMore: loadMoreCustomer,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// ===== FORMIK SETUP =====
const formik = useFormik<DailyMarketingReportFilterType>({
@@ -132,12 +141,10 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
sort_by: values.sort_by || undefined,
});
filterModal.closeModal();
setIsSubmitted(true);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
filterModal.closeModal();
},
});
@@ -211,31 +218,28 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
// ===== DATA FETCHING =====
const { data: dailyMarketings, isLoading } = useSWR(
isSubmitted
? () => {
const params = new URLSearchParams();
() => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date)
params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
return ['daily-marketing-report', params.toString()];
}
: null,
return ['daily-marketing-report', params.toString()];
},
([, params]) =>
MarketingReportApi.getAllDailyMarketingFetcher(
`${MarketingReportApi.basePath}?${params}`
@@ -508,7 +512,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
},
{
id: 'warehouse',
header: 'Gudang',
header: 'Gudang Fisik',
accessorKey: 'warehouse',
cell: ({ row }) => row.original.warehouse.name,
footer: () => <div className='font-semibold text-gray-900'>-</div>,
@@ -648,21 +652,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
<>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<DailyMarketingReportSkeleton
columns={getTableColumns()}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
{isLoading && (
<DailyMarketingReportSkeleton
columns={getTableColumns()}
icon={
@@ -676,7 +666,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
title='Memuat Data Penjualan Harian'
subtitle='Silakan tunggu sebentar...'
/>
) : data.length === 0 ? (
)}
{!isLoading && data.length === 0 && (
<DailyMarketingReportSkeleton
columns={getTableColumns()}
icon={
@@ -690,7 +682,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
)}
{!isLoading && data.length > 0 && (
<Table
data={data}
columns={getTableColumns()}
@@ -837,6 +831,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
}}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreArea}
/>
{/* Location Filter */}
@@ -854,12 +850,14 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
}}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocation}
/>
{/* Warehouse Filter */}
<SelectInput
label='Gudang'
placeholder='Pilih Gudang'
label='Gudang Fisik'
placeholder='Pilih Gudang Fisik'
options={warehouseOptions}
isLoading={isLoadingWarehouses}
value={warehouseValue}
@@ -871,6 +869,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
}}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
/>
{/* Customer Filter */}
@@ -888,6 +888,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
}}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomer}
/>
{/* Filter By Date Type */}
@@ -71,24 +71,32 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
const filterModal = useModal();
// ===== OPTIONS =====
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
const {
options: areaOptions,
isLoadingOptions: isLoadingAreas,
setInputValue: setAreaInputValue,
loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const {
options: locationOptions,
isLoadingOptions: isLoadingLocations,
setInputValue: setLocationInputValue,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const {
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect(
ProjectFlockKandangApi.basePath,
'id',
'name',
'name_with_period',
'search'
);
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } =
useSelect(
ProjectFlockKandangApi.basePath,
'id',
'name_with_period',
'search'
);
const showUnrecordedOptions = useMemo(
() => [
{ value: 'false', label: 'Sembunyikan' },
@@ -783,6 +791,10 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
[data, perWeightRangeSummary]
);
useEffectHook(() => {
filterModal.openModal();
}, []);
return (
<>
{TabActionsElement}
@@ -918,6 +930,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
isLoading={isLoadingAreas}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreArea}
/>
{/* Location Filter */}
@@ -937,6 +951,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
isLoading={isLoadingLocations}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocation}
/>
{/* Kandang Filter */}
@@ -956,6 +972,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
isLoading={isLoadingKandangs}
isClearable
className={{ wrapper: 'w-full' }}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
/>
{/* Weight Range Filter */}
@@ -43,15 +43,7 @@ export const ProductionResultFilterSchema = yup.object({
}
return !!value;
}),
kandang_id: yup
.mixed<OptionType>()
.required('Kandang wajib dipilih')
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
kandang_id: yup.mixed<OptionType>().nullable(),
}) as yup.ObjectSchema<ProductionResultFilterFormType>;
export type ProductionResultFilterValues = yup.InferType<
@@ -46,6 +46,7 @@ import Modal, { useModal } from '@/components/Modal';
import { formatNumber } from '@/lib/helper';
import Pagination from '@/components/Pagination';
import ProductionResultSkeleton from '@/components/pages/report/production-result/skeleton/ProductionResultSkeleton';
import { ProjectFlock } from '@/types/api/production/project-flock';
interface ProductionResultTabProps {
tabId: string;
@@ -238,6 +239,17 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
? String(values.kandang_id.value)
: undefined,
});
const selectedProjectFlockKandangRawData = isResponseSuccess(
projectFlockKandangsRawData
)
? projectFlockKandangsRawData.data.find(
(item) => item.id === values.kandang_id?.value
)
: undefined;
setSelectedProjectFlockKandang(selectedProjectFlockKandangRawData);
filterModal.closeModal();
setIsSubmitted(true);
setPage(1);
@@ -255,6 +267,9 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
formik.validateForm();
};
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
useState<ProjectFlockKandang | undefined>();
// ===== OPTIONS =====
const {
setInputValue: setAreaInputValue,
@@ -279,7 +294,7 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlocks,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>(
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
@@ -300,10 +315,11 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangs,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>(
rawData: projectFlockKandangsRawData,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'kandang.name',
'name_with_period',
'search',
{
area_id: formik.values.area_id?.value
@@ -359,13 +375,15 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
([url]: string[]) => httpClient<BaseApiResponse<ProjectFlockKandang[]>>(url)
);
const projectFlockKandangs = useMemo(
() =>
isResponseSuccess(projectFlockKandangsData)
? projectFlockKandangsData.data
: null,
[projectFlockKandangsData]
);
const projectFlockKandangs = useMemo(() => {
if (selectedProjectFlockKandang) {
return [selectedProjectFlockKandang];
}
return isResponseSuccess(projectFlockKandangsData)
? projectFlockKandangsData.data
: null;
}, [projectFlockKandangsData, selectedProjectFlockKandang]);
const projectFlockKandangMetadata = useMemo(
() =>
@@ -631,6 +649,10 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
// Render the TabActions component
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => {
filterModal.openModal();
}, []);
return (
<>
{TabActionsElement}
@@ -800,7 +822,6 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
/>
<SelectInput
required
label='Kandang'
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
+12
View File
@@ -20,6 +20,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
'lti.daily_checklist.master_data.employee',
'lti.daily_checklist.master_data.activity',
'lti.daily_checklist.master_data.configuration',
'lti.daily_checklist.master_data.kandang',
],
submenu: [
{
@@ -66,6 +67,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/daily-checklist/master-data/activity',
permission: ['lti.daily_checklist.master_data.activity'],
},
{
text: 'Kandang',
link: '/daily-checklist/master-data/kandang',
permission: ['lti.daily_checklist.master_data.kandang'],
},
{
text: 'Konfigurasi',
link: '/daily-checklist/master-data/configuration',
@@ -549,6 +555,12 @@ export const APPROVAL_WORKFLOWS = {
],
};
export const PROJECT_FLOCK_STATUS = {
PENGAJUAN: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[0].step_name,
AKTIF: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[1].step_name,
SELESAI: APPROVAL_WORKFLOWS.PROJECT_FLOCKS[2].step_name,
} as const;
export const ACCEPTED_FILE_TYPE = {
PDF: {
'application/pdf': ['.pdf'],
+3
View File
@@ -21,6 +21,9 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/daily-checklist/master-data/configuration/': [
'lti.daily_checklist.master_data.configuration',
],
'/daily-checklist/master-data/kandang/': [
'lti.daily_checklist.master_data.kandang',
],
// Production
// Production - Project Flock
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { Check, ChevronsUpDown, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/helper';
import { Button } from '@/figma-make/components/base/button';
import {
@@ -29,6 +29,8 @@ interface MultiSelectProps {
selected: string[];
onChange: (selected: string[]) => void;
onSearchChange?: (value: string) => void;
onLoadMore?: () => void;
isLoadingMore?: boolean;
placeholder?: string;
className?: string;
disabled?: boolean;
@@ -39,6 +41,8 @@ export function MultiSelect({
selected,
onChange,
onSearchChange,
onLoadMore,
isLoadingMore,
placeholder = 'Select items...',
className,
disabled,
@@ -115,7 +119,18 @@ export function MultiSelect({
onValueChange={onSearchChange}
/>
<CommandEmpty>No item found.</CommandEmpty>
<CommandList className='max-h-[300px] overflow-y-auto'>
<CommandList
className='max-h-[300px] overflow-y-auto'
onScroll={(e) => {
const target = e.currentTarget;
if (
target.scrollHeight - target.scrollTop <=
target.clientHeight + 1
) {
onLoadMore?.();
}
}}
>
<CommandGroup className='overflow-visible'>
{options.map((option) => (
<CommandItem
@@ -134,6 +149,11 @@ export function MultiSelect({
{option.label}
</CommandItem>
))}
{isLoadingMore && (
<div className='py-4 flex justify-center w-full'>
<Loader2 className='h-4 w-4 animate-spin text-muted-foreground' />
</div>
)}
</CommandGroup>
</CommandList>
</Command>
+7 -2
View File
@@ -55,7 +55,11 @@ function SelectContent({
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
onScroll?: React.UIEventHandler<HTMLDivElement>;
}) {
const { onScroll, ...restProps } = props;
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
@@ -67,7 +71,7 @@ function SelectContent({
className
)}
position={position}
{...props}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
@@ -76,6 +80,7 @@ function SelectContent({
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
onScroll={onScroll}
>
{children}
</SelectPrimitive.Viewport>
@@ -2,7 +2,16 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { Plus, X, Save, Send, Info, FilePlus, ListChecks } from 'lucide-react';
import {
Plus,
X,
Save,
Send,
Info,
FilePlus,
ListChecks,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Label } from '@/figma-make/components/base/label';
@@ -26,7 +35,6 @@ import {
import { DatePicker } from '@/figma-make/components/base/date-picker';
import { toast } from 'sonner';
import { useSelect } from '@/components/input/SelectInput';
import { KandangApi } from '@/services/api/master-data';
import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import useSWR from 'swr';
@@ -43,6 +51,7 @@ import DropFileInput from '@/components/input/DropFileInput';
import Link from 'next/link';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { Icon } from '@iconify/react';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
// Static categories
const CATEGORIES = [
@@ -86,16 +95,11 @@ export function DailyChecklistContent() {
searchParams.get('category') || ''
);
const { options: kandangOptions } = useSelect(
KandangApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '100',
}
);
const {
options: kandangOptions,
isLoadingMore: isLoadingMoreKandang,
loadMore: loadMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
const { data: phases } = useSWR<
BaseApiResponse<Phase[] | undefined>,
@@ -168,6 +172,16 @@ export function DailyChecklistContent() {
const [documents, setDocuments] = useState<File[]>([]);
const [deletedDocumentIds, setDeletedDocumentIds] = useState<number[]>([]);
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
if (!isLoadingMoreKandang) {
loadMoreKandang();
}
}
};
// Sync state to URL query params
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
@@ -301,93 +315,8 @@ export function DailyChecklistContent() {
checkAndLoadChecklist();
}, [date, kandangId, selectedCategory]);
// Load employees when kandang changes
useEffect(() => {
if (kandangId) {
// ✅ Clear selected employees ketika kandang berubah (reset ABK assignment)
setSelectedEmployees([]);
setAssignments({});
} else {
setSelectedEmployees([]);
setAssignments({});
}
}, [kandangId]);
// Load activities and tasks when phases change
useEffect(() => {
const loadAssignments = async (taskIds: string[]) => {
if (taskIds.length === 0) return;
try {
const existingDailyChecklist =
await DailyChecklistApi.getOneDailyChecklist(
String(dailyChecklistId)
);
if (isResponseError(existingDailyChecklist)) {
console.error(
'Error loading assignments:',
existingDailyChecklist.message
);
return;
}
// set existing document
setExistingDocuments(existingDailyChecklist?.data.document_urls || []);
// Build assignments map
const assignmentMap: {
[taskId: string]: {
[employeeId: string]: { checked: boolean; note: string };
};
} = {};
(existingDailyChecklist?.data.tasks || []).forEach(
(dailyChecklistTask) => {
if (!assignmentMap[dailyChecklistTask.id]) {
assignmentMap[dailyChecklistTask.id] = {};
}
dailyChecklistTask.assignments.forEach((assignment) => {
if (!assignmentMap[dailyChecklistTask.id]) {
assignmentMap[dailyChecklistTask.id] = {};
}
assignmentMap[dailyChecklistTask.id][assignment.employee.id] = {
checked: assignment.checked,
note: assignment.note || '',
};
});
}
);
setAssignments(assignmentMap);
// Load employees from assignments
const employeeIds = Array.from(
new Set(
(existingDailyChecklist?.data.assigned_employees || []).map(
(a) => a.id
)
)
);
if (employeeIds.length > 0) {
const existingDailyChecklist =
await DailyChecklistApi.getOneDailyChecklist(
String(dailyChecklistId)
);
if (isResponseSuccess(existingDailyChecklist)) {
setSelectedEmployees(
existingDailyChecklist.data.assigned_employees
);
}
}
} catch (error) {
console.error('Error loading assignments:', error);
}
};
const loadActivitiesAndTasks = async () => {
if (!dailyChecklistId || selectedPhaseIds.length === 0) {
setActivitiesByPhase({});
@@ -462,6 +391,87 @@ export function DailyChecklistContent() {
loadActivitiesAndTasks();
}, [dailyChecklistId, selectedPhaseIds]);
// Load employees when kandang changes
useEffect(() => {
if (kandangId) {
// ✅ Clear selected employees ketika kandang berubah (reset ABK assignment)
setSelectedEmployees([]);
setAssignments({});
} else {
setSelectedEmployees([]);
setAssignments({});
}
}, [kandangId]);
const loadAssignments = async (taskIds: string[]) => {
if (taskIds.length === 0) return;
try {
const existingDailyChecklist =
await DailyChecklistApi.getOneDailyChecklist(String(dailyChecklistId));
if (isResponseError(existingDailyChecklist)) {
console.error(
'Error loading assignments:',
existingDailyChecklist.message
);
return;
}
// set existing document
setExistingDocuments(existingDailyChecklist?.data.document_urls || []);
// Build assignments map
const assignmentMap: {
[taskId: string]: {
[employeeId: string]: { checked: boolean; note: string };
};
} = {};
(existingDailyChecklist?.data.tasks || []).forEach(
(dailyChecklistTask) => {
if (!assignmentMap[dailyChecklistTask.id]) {
assignmentMap[dailyChecklistTask.id] = {};
}
dailyChecklistTask.assignments.forEach((assignment) => {
if (!assignmentMap[dailyChecklistTask.id]) {
assignmentMap[dailyChecklistTask.id] = {};
}
assignmentMap[dailyChecklistTask.id][assignment.employee.id] = {
checked: assignment.checked,
note: assignment.note || '',
};
});
}
);
setAssignments(assignmentMap);
// Load employees from assignments
const employeeIds = Array.from(
new Set(
(existingDailyChecklist?.data.assigned_employees || []).map(
(a) => a.id
)
)
);
if (employeeIds.length > 0) {
const existingDailyChecklist =
await DailyChecklistApi.getOneDailyChecklist(
String(dailyChecklistId)
);
if (isResponseSuccess(existingDailyChecklist)) {
setSelectedEmployees(existingDailyChecklist.data.assigned_employees);
}
}
} catch (error) {
console.error('Error loading assignments:', error);
}
};
// Phase selection modal
const handleAddPhase = () => {
if (!selectedCategory) {
@@ -998,7 +1008,7 @@ export function DailyChecklistContent() {
>
<SelectValue placeholder='Pilih kandang' />
</SelectTrigger>
<SelectContent>
<SelectContent onScroll={handleKandangScroll}>
{kandangOptions.map((kandang) => (
<SelectItem
key={kandang.value}
@@ -1007,6 +1017,12 @@ export function DailyChecklistContent() {
{kandang.label}
</SelectItem>
))}
{isLoadingMoreKandang && (
<div className='flex justify-center p-2'>
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
</div>
)}
</SelectContent>
</Select>
</div>
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import moment from 'moment';
import {
Card,
CardContent,
@@ -16,7 +17,7 @@ import {
SelectValue,
} from '@/figma-make/components/base/select';
import { Badge } from '@/figma-make/components/base/badge';
import { Users, AlertCircle, Info } from 'lucide-react';
import { Users, AlertCircle, Info, Loader2 } from 'lucide-react';
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
import {
BarChart,
@@ -36,10 +37,10 @@ import { DailyChecklistSummary } from '@/types/api/daily-checklist/daily-checkli
import { AxiosError } from 'axios';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist';
import { KandangApi } from '@/services/api/master-data';
import { useSelect } from '@/components/input/SelectInput';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate } from '@/lib/helper';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
const KANDANG_COLORS = [
'#0069e0', // Blue (primary)
@@ -59,10 +60,17 @@ const CATEGORY_LABELS: { [key: string]: string } = {
produksi_close: 'Produksi Close',
};
const getThisMonthRange = () => ({
dateFrom: moment().startOf('month').format('YYYY-MM-DD'),
dateTo: moment().endOf('month').format('YYYY-MM-DD'),
});
export function Dashboard() {
const defaultDateRange = getThisMonthRange();
// Filters
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [dateFrom, setDateFrom] = useState(defaultDateRange.dateFrom);
const [dateTo, setDateTo] = useState(defaultDateRange.dateTo);
const [kandangFilter, setKandangFilter] = useState('ALL');
const [categoryFilter, setCategoryFilter] = useState('ALL');
@@ -77,16 +85,20 @@ export function Dashboard() {
httpClientFetcher
);
const { options: kandangOptions } = useSelect(
KandangApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '100',
const {
options: kandangOptions,
loadMore: loadMoreKandang,
isLoadingMore: isLoadingMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
if (!isLoadingMoreKandang) {
loadMoreKandang();
}
}
);
};
const kandangColorMap: { [key: string]: string } = {};
(kandangOptions || []).forEach((k, index) => {
@@ -164,7 +176,7 @@ export function Dashboard() {
>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectContent onScroll={handleKandangScroll}>
<SelectItem value='ALL'>Semua Kandang</SelectItem>
{kandangOptions.map((kandang) => (
<SelectItem
@@ -174,6 +186,11 @@ export function Dashboard() {
{kandang.label}
</SelectItem>
))}
{isLoadingMoreKandang && (
<div className='flex justify-center p-2'>
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
</div>
)}
</SelectContent>
</Select>
</div>
@@ -1,7 +1,15 @@
'use client';
import { useState } from 'react';
import { Eye, CheckCircle, XCircle, Search, Trash2, Edit } from 'lucide-react';
import {
Eye,
CheckCircle,
XCircle,
Search,
Trash2,
Edit,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Badge } from '@/figma-make/components/base/badge';
@@ -34,9 +42,9 @@ import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist';
import { cn } from '@/lib/helper';
import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput';
import { KandangApi } from '@/services/api/master-data';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import RequirePermission from '@/components/helper/RequirePermission';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
const STATUS_OPTIONS = [
{ value: 'ALL', label: 'Semua Status' },
@@ -93,21 +101,25 @@ export function ListDailyChecklistContent() {
}
);
const { options: kandangOptions } = useSelect(
KandangApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '100',
}
);
const {
options: kandangOptions,
isLoadingMore: isLoadingMoreKandang,
loadMore: loadMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
const checklistList = isResponseSuccess(checklistListRes)
? checklistListRes.data || []
: [];
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
if (!isLoadingMoreKandang) {
loadMoreKandang();
}
}
};
// Modals
const [showApproveModal, setShowApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
@@ -124,7 +136,7 @@ export function ListDailyChecklistContent() {
const handleEdit = (item: DailyChecklist) => {
const formattedDate = new Date(item.date).toISOString().split('T')[0];
const kandangId = item.kandang.id;
const kandangId = item.kandang?.id ?? '';
const category = item.category;
router.push(
@@ -323,7 +335,7 @@ export function ListDailyChecklistContent() {
accessorKey: 'kandang',
header: 'Kandang',
enableSorting: false,
cell: ({ row }) => row.original.kandang.name,
cell: ({ row }) => row.original.kandang?.name ?? '-',
},
{
accessorKey: 'category',
@@ -490,7 +502,7 @@ export function ListDailyChecklistContent() {
>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectContent onScroll={handleKandangScroll}>
<SelectItem value='ALL'>Semua Kandang</SelectItem>
{kandangOptions.map((kandang) => (
<SelectItem
@@ -500,6 +512,11 @@ export function ListDailyChecklistContent() {
{kandang.label}
</SelectItem>
))}
{isLoadingMoreKandang && (
<div className='flex justify-center p-2'>
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
</div>
)}
</SelectContent>
</Select>
</div>
@@ -610,7 +627,7 @@ export function ListDailyChecklistContent() {
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{selectedItem.kandang.name}
{selectedItem.kandang?.name ?? '-'}
</span>
</div>
<div className='flex justify-between text-sm'>
@@ -670,7 +687,7 @@ export function ListDailyChecklistContent() {
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{selectedItem.kandang.name}
{selectedItem.kandang?.name ?? '-'}
</span>
</div>
<div className='flex justify-between text-sm'>
@@ -743,7 +760,7 @@ export function ListDailyChecklistContent() {
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{selectedItem.kandang.name}
{selectedItem.kandang?.name ?? '-'}
</span>
</div>
<div className='flex justify-between text-sm'>
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { ArrowLeft, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
@@ -137,7 +137,15 @@ export function DetailDailyChecklistContent() {
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const fetchChecklistDetail = useCallback(async () => {
useEffect(() => {
if (checklistId) {
fetchChecklistDetail();
} else {
router.push('/404');
}
}, [checklistId]);
const fetchChecklistDetail = async () => {
if (!checklistId) {
console.warn('checklistId missing');
setLoading(false);
@@ -164,7 +172,7 @@ export function DetailDailyChecklistContent() {
const checklistData = {
id: rawDetailChecklist?.id,
date: rawDetailChecklist?.date,
kandang_id: rawDetailChecklist?.kandang.id,
kandang_id: rawDetailChecklist?.kandang?.id,
category: rawDetailChecklist?.category,
status: rawDetailChecklist?.status,
reject_reason: rawDetailChecklist?.reject_reason,
@@ -312,15 +320,7 @@ export function DetailDailyChecklistContent() {
} finally {
setLoading(false);
}
}, [checklistId, router]);
useEffect(() => {
if (checklistId) {
fetchChecklistDetail();
} else {
router.push('/404');
}
}, [checklistId, fetchChecklistDetail, router]);
};
const groupDetailData = (rows: ChecklistDetailRow[]) => {
// Group by phase_id
@@ -1,7 +1,14 @@
'use client';
import { useState } from 'react';
import { Plus, MoreVertical, Pencil, Trash2, Search } from 'lucide-react';
import {
Plus,
MoreVertical,
Pencil,
Trash2,
Search,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Label } from '@/figma-make/components/base/label';
@@ -49,8 +56,8 @@ import { cn } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput';
import { KandangApi } from '@/services/api/master-data';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
export function MasterEmployeeContent() {
const {
@@ -85,16 +92,20 @@ export function MasterEmployeeContent() {
keepPreviousData: true,
}
);
const { options: kandangOptions } = useSelect(
KandangApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '100',
const {
options: kandangOptions,
loadMore: loadMoreKandang,
isLoadingMore: isLoadingMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
if (!isLoadingMoreKandang) {
loadMoreKandang();
}
}
);
};
const [showModal, setShowModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -351,7 +362,7 @@ export function MasterEmployeeContent() {
<SelectTrigger className='w-[180px] border-gray-200'>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectContent onScroll={handleKandangScroll}>
<SelectItem value='all'>Semua Kandang</SelectItem>
{kandangOptions.map((kandang) => (
<SelectItem
@@ -361,6 +372,11 @@ export function MasterEmployeeContent() {
{kandang.label}
</SelectItem>
))}
{isLoadingMoreKandang && (
<div className='flex justify-center p-2'>
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
</div>
)}
</SelectContent>
</Select>
@@ -471,6 +487,12 @@ export function MasterEmployeeContent() {
kandang_ids: selected.map((id) => Number(id)),
})
}
onLoadMore={() => {
if (!isLoadingMoreKandang) {
loadMoreKandang();
}
}}
isLoadingMore={isLoadingMoreKandang}
placeholder='Pilih kandang'
className='mt-1.5'
/>
@@ -0,0 +1,585 @@
'use client';
import { useState } from 'react';
import { Plus, MoreVertical, Pencil, Trash2, Search } from 'lucide-react';
import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button';
import { Label } from '@/figma-make/components/base/label';
import { Input } from '@/figma-make/components/base/input';
import { Badge } from '@/figma-make/components/base/badge';
import { MultiSelect } from '@/figma-make/components/base/multi-select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/figma-make/components/base/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/figma-make/components/base/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/figma-make/components/base/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/figma-make/components/base/dropdown-menu';
import { toast } from 'sonner';
import useSWR from 'swr';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
import Table from '@/components/Table';
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { UserApi } from '@/services/api/user';
export function MasterKandangContent() {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
location_id: '',
status: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
location_id: 'location_id',
},
});
const {
data: dailyChecklistKandangs,
isLoading: isLoadingDailyChecklistKandangs,
mutate: refreshDailyChecklistKandangs,
} = useSWR(
`${DailyChecklistKandangApi.basePath}${getTableFilterQueryString()}`,
DailyChecklistKandangApi.getAllFetcher,
{
keepPreviousData: true,
}
);
const { options: locationOptions } = useSelect(
LocationApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '100',
}
);
const { options: picOptions } = useSelect(
UserApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '100',
}
);
const {
options: kandangOptions,
isLoadingMore: isLoadingKandangOptionsMore,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name');
const [showModal, setShowModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [kandangToDelete, setKandangToDelete] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
const [kandangForm, setKandangForm] = useState({
id: 0,
name: '',
location_id: 0,
pic_id: 0,
// recording_kandangs: [] as number[],
});
const dailyChecklistKandangColumns: ColumnDef<DailyChecklistKandang>[] = [
{
id: 'name',
header: 'Nama',
accessorKey: 'name',
enableSorting: false,
},
{
id: 'location',
header: 'Lokasi',
accessorKey: 'location',
enableSorting: false,
cell: ({ row }) => row.original.location.name ?? '-',
},
{
id: 'pic',
header: 'PIC',
accessorKey: 'pic',
enableSorting: false,
cell: ({ row }) => row.original.pic.name ?? '-',
},
{
id: 'recording_kandangs',
header: 'Kandang Recording',
accessorKey: 'recording_kandangs',
enableSorting: false,
cell: ({ row }) =>
row.original.recording_kandangs?.length > 0
? row.original.recording_kandangs.map((item) => item.name).join(', ')
: '-',
},
{
id: 'action',
header: 'Aksi',
accessorKey: 'action',
enableSorting: false,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 w-8 p-0 hover:bg-gray-100'
>
<MoreVertical className='h-4 w-4 text-gray-600' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
<Pencil className='mr-2 h-4 w-4' />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(row.original.id)}
className='text-red-600'
>
<Trash2 className='mr-2 h-4 w-4' />
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
const handleAdd = () => {
setModalMode('create');
setKandangForm({
id: 0,
name: '',
location_id: 0,
pic_id: 0,
// recording_kandangs: []
});
setShowModal(true);
};
const handleEdit = (dailyChecklistKandang: DailyChecklistKandang) => {
setModalMode('edit');
setKandangForm({
id: dailyChecklistKandang.id,
name: dailyChecklistKandang.name,
location_id: dailyChecklistKandang.location.id,
pic_id: dailyChecklistKandang.pic.id,
// recording_kandangs:
// dailyChecklistKandang.recording_kandangs.map((item) => item.id) ?? [],
});
setShowModal(true);
};
const handleSave = async () => {
if (!kandangForm.name.trim()) {
toast.error('Nama harus diisi');
return;
}
if (!kandangForm.location_id) {
toast.error('Lokasi wajib diisi');
return;
}
// if (!kandangForm.recording_kandangs.length) {
// toast.error('Kandang recording wajib diisi');
// return;
// }
setLoading(true);
try {
if (modalMode === 'create') {
const createDailyChecklistKandangResponse =
await DailyChecklistKandangApi.create({
name: kandangForm.name.trim(),
location_id: kandangForm.location_id,
pic_id: kandangForm.pic_id,
// recording_kandang_ids: kandangForm.recording_kandangs,
});
if (isResponseError(createDailyChecklistKandangResponse)) {
console.error(
'Error creating kandang:',
createDailyChecklistKandangResponse.message
);
toast.error('Gagal menambahkan kandang');
return;
}
refreshDailyChecklistKandangs();
toast.success('Kandang berhasil ditambahkan');
} else {
const updateDailyChecklistKandangResponse =
await DailyChecklistKandangApi.update(kandangForm.id, {
name: kandangForm.name.trim(),
location_id: kandangForm.location_id,
pic_id: kandangForm.pic_id,
// recording_kandang_ids: kandangForm.recording_kandangs,
});
if (isResponseError(updateDailyChecklistKandangResponse)) {
console.error(
'Error updating kandang:',
updateDailyChecklistKandangResponse.message
);
toast.error('Gagal menambahkan Kandang');
return;
}
refreshDailyChecklistKandangs();
toast.success('Kandang berhasil diubah');
}
setShowModal(false);
setKandangForm({
id: 0,
name: '',
location_id: 0,
pic_id: 0,
// recording_kandangs: [],
});
} catch (error) {
console.error('Error saving kandang:', error);
toast.error('Terjadi kesalahan saat menyimpan kandang');
} finally {
setLoading(false);
}
};
const handleDeleteClick = (kandangId: number) => {
setKandangToDelete(kandangId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = async () => {
if (!kandangToDelete) return;
setLoading(true);
try {
const deleteKandangResponse =
await DailyChecklistKandangApi.delete(kandangToDelete);
if (isResponseError(deleteKandangResponse)) {
console.error('Error deleting kandang:', deleteKandangResponse.message);
toast.error('Gagal menghapus kandang');
return;
}
refreshDailyChecklistKandangs();
toast.success('Kandang berhasil dihapus');
setShowDeleteConfirm(false);
setKandangToDelete(null);
} catch (error) {
console.error('Error deleting kandang:', error);
toast.error('Terjadi kesalahan saat menghapus kandang');
} finally {
setLoading(false);
}
};
if (isLoadingDailyChecklistKandangs && !dailyChecklistKandangs) {
return (
<div className='min-h-screen'>
<div className='p-6'>
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Master Kandang
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data <span className='text-[#0069e0]'>Kandang</span>
</p>
</div>
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-12 text-center text-gray-500'>
Memuat data...
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className='min-h-screen'>
<div className='p-6'>
{/* Page Title */}
<div className='mb-6'>
<h1 className='text-2xl font-semibold text-gray-900'>
Master Kandang
</h1>
<p className='text-sm text-gray-600 mt-1'>
Master Data <span className='text-[#0069e0]'>Kandang</span>
</p>
</div>
{/* Main Card */}
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
<CardContent className='p-0'>
{/* Single Toolbar Row */}
<div className='flex flex-wrap items-center justify-between gap-4 p-6 border-b border-gray-200/60'>
{/* LEFT: Search + Filters */}
<div className='flex items-center gap-3 flex-wrap'>
<div className='relative'>
<Search className='absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4' />
<DebouncedTextInput
name='search'
placeholder='Cari kandang...'
value={tableFilterState.search}
onChange={(e) => updateFilter('search', e.target.value)}
className={{
wrapper: 'w-full sm:w-[280px] border-gray-200',
inputWrapper: 'px-3 py-2 h-fit rounded-md',
input: 'text-sm',
}}
startAdornment={
<Search className='text-gray-400 w-4 h-4' />
}
/>
</div>
<Select
value={tableFilterState.location_id}
onValueChange={(value) =>
updateFilter('location_id', value === 'all' ? '' : value)
}
>
<SelectTrigger className='w-[180px] border-gray-200'>
<SelectValue placeholder='Semua Lokasi' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>Semua Lokasi</SelectItem>
{locationOptions.map((kandang) => (
<SelectItem
key={kandang.value}
value={String(kandang.value)}
>
{kandang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* RIGHT: Export + Add */}
<div className='flex items-center gap-2 flex-wrap'>
<Button
onClick={handleAdd}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
<Plus className='w-4 h-4 mr-2' />
Tambah Kandang
</Button>
</div>
</div>
{/* Table */}
<Table<DailyChecklistKandang>
data={
isResponseSuccess(dailyChecklistKandangs)
? dailyChecklistKandangs?.data
: []
}
columns={dailyChecklistKandangColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(dailyChecklistKandangs)
? dailyChecklistKandangs?.meta?.page
: 0
}
totalItems={
isResponseSuccess(dailyChecklistKandangs)
? dailyChecklistKandangs?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingDailyChecklistKandangs}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(dailyChecklistKandangs) &&
dailyChecklistKandangs?.data?.length === 0,
}),
tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-none',
headerRowClassName: 'bg-gray-50/50',
headerColumnClassName:
'text-left py-3.5 px-6 text-sm font-semibold text-gray-700',
paginationClassName: 'px-4',
}}
/>
</CardContent>
</Card>
</div>
{/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>
{modalMode === 'create' ? 'Tambah Kandang' : 'Edit Kandang'}
</DialogTitle>
<DialogDescription>
{modalMode === 'create'
? 'Masukkan detail Kandang baru'
: 'Ubah detail Kandang'}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div>
<Label htmlFor='nama-kandang'>
Nama Kandang <span className='text-red-500'>*</span>
</Label>
<Input
id='nama-kandang'
value={kandangForm.name}
onChange={(e) =>
setKandangForm({ ...kandangForm, name: e.target.value })
}
placeholder='Masukkan nama Kandang'
className='mt-1.5'
disabled={loading}
/>
</div>
<div>
<Label htmlFor='category'>
Lokasi <span className='text-red-500'>*</span>
</Label>
<Select
value={
kandangForm.location_id ? String(kandangForm.location_id) : ''
}
onValueChange={(value) =>
setKandangForm({ ...kandangForm, location_id: Number(value) })
}
>
<SelectTrigger id='category' className='mt-1.5'>
<SelectValue placeholder='Pilih lokasi' />
</SelectTrigger>
<SelectContent>
{locationOptions.map((cat) => (
<SelectItem key={cat.value} value={String(cat.value)}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor='pic'>
PIC <span className='text-red-500'>*</span>
</Label>
<Select
value={kandangForm.pic_id ? String(kandangForm.pic_id) : ''}
onValueChange={(value) =>
setKandangForm({ ...kandangForm, pic_id: Number(value) })
}
>
<SelectTrigger id='pic' className='mt-1.5'>
<SelectValue placeholder='Pilih PIC' />
</SelectTrigger>
<SelectContent>
{picOptions.map((cat) => (
<SelectItem key={cat.value} value={String(cat.value)}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowModal(false)}
disabled={loading}
>
Batal
</Button>
<Button
onClick={handleSave}
disabled={loading}
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
>
{loading ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent className='bg-white rounded-xl shadow-lg sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Hapus Kandang?</AlertDialogTitle>
<AlertDialogDescription>
Data Kandang akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={loading}
className='bg-red-600 hover:bg-red-700 text-white'
>
{loading ? 'Menghapus...' : 'Hapus'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -11,7 +11,7 @@ import {
SelectValue,
} from '@/figma-make/components/base/select';
import { useSelect } from '@/components/input/SelectInput';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { AreaApi, LocationApi } from '@/services/api/master-data';
import useSWR from 'swr';
import { BaseApiResponse } from '@/types/api/api-general';
import { DailyChecklistReport } from '@/types/api/daily-checklist/daily-checklist';
@@ -26,7 +26,8 @@ import { ColumnDef } from '@tanstack/react-table';
import { PhaseApi } from '@/services/api/daily-checklist/phase';
import { EmployeeApi } from '@/services/api/daily-checklist/employee';
import { Button } from '@/figma-make/components/base/button';
import { Download } from 'lucide-react';
import { Download, Loader2 } from 'lucide-react';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
const MONTH_OPTIONS = [
{ value: '1', label: 'Januari' },
@@ -129,18 +130,23 @@ export function DailyChecklistReportsContent() {
}
);
const { options: kandangOptions } = useSelect(
KandangApi.basePath,
'id',
'name',
'search',
{
page: '1',
limit: '100',
area_id: tableFilterState.area_id,
location_id: tableFilterState.location_id,
const {
options: kandangOptions,
loadMore: loadMoreKandang,
isLoadingMore: isLoadingMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
area_id: tableFilterState.area_id,
location_id: tableFilterState.location_id,
});
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
if (!isLoadingMoreKandang) {
loadMoreKandang();
}
}
);
};
const { options: phaseOptions } = useSelect(
PhaseApi.basePath,
@@ -435,7 +441,7 @@ export function DailyChecklistReportsContent() {
>
<SelectValue placeholder='Semua Kandang' />
</SelectTrigger>
<SelectContent>
<SelectContent onScroll={handleKandangScroll}>
<SelectItem value='ALL'>Semua Kandang</SelectItem>
{kandangOptions.map((kandang) => (
<SelectItem
@@ -445,6 +451,11 @@ export function DailyChecklistReportsContent() {
{kandang.label}
</SelectItem>
))}
{isLoadingMoreKandang && (
<div className='flex justify-center p-2'>
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
</div>
)}
</SelectContent>
</Select>
</div>
+39
View File
@@ -0,0 +1,39 @@
type SearchParamsLike = {
get: (name: string) => string | null;
};
const EXPENSE_LIST_PATH = '/expense';
export const getExpenseListReturnTo = (searchParams: SearchParamsLike) => {
const existingReturnTo = searchParams.get('returnTo');
if (existingReturnTo?.startsWith(EXPENSE_LIST_PATH)) {
return existingReturnTo;
}
const params = new URLSearchParams();
const page = searchParams.get('page');
const limit = searchParams.get('limit');
if (page) params.set('page', page);
if (limit) params.set('limit', limit);
const queryString = params.toString();
return queryString
? `${EXPENSE_LIST_PATH}?${queryString}`
: EXPENSE_LIST_PATH;
};
export const buildExpenseActionHref = (
path: string,
expenseId: number | string,
searchParams: SearchParamsLike
) => {
const params = new URLSearchParams({
expenseId: String(expenseId),
returnTo: getExpenseListReturnTo(searchParams),
});
return `${path}?${params.toString()}`;
};
-14
View File
@@ -305,17 +305,3 @@ export function transformConstants(
},
};
}
export function omit<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
keys: K | K[]
): Omit<T, K> {
const keysArray = Array.isArray(keys) ? keys : [keys];
const result = { ...obj };
keysArray.forEach((key) => {
delete result[key];
});
return result;
}
+57 -55
View File
@@ -76,13 +76,13 @@ export const calculateTrading = (
case 'unit_price':
case 'qty': {
if (unitPrice > 0 && qty > 0) {
setFieldValue('total_price', roundPrice(unitPrice * qty));
setFieldValue('total_price', unitPrice * qty);
}
break;
}
case 'total_price': {
if (totalPrice > 0 && qty > 0) {
setFieldValue('unit_price', roundPrice(totalPrice / qty));
setFieldValue('unit_price', totalPrice / qty);
}
break;
}
@@ -112,7 +112,7 @@ export const calculateAyamPullet = (
case 'qty': {
// total_price = unit_price × week × qty
if (unitPrice > 0 && week > 0 && qty > 0) {
setFieldValue('total_price', roundPrice(unitPrice * week * qty));
setFieldValue('total_price', unitPrice * week * qty);
}
// total_weight = avg_weight × qty
if (avgWeight > 0 && qty > 0) {
@@ -135,7 +135,7 @@ export const calculateAyamPullet = (
case 'total_price': {
// Reverse: unit_price = total_price / (week × qty)
if (totalPrice > 0 && week > 0 && qty > 0) {
setFieldValue('unit_price', roundPrice(totalPrice / (week * qty)));
setFieldValue('unit_price', totalPrice / (week * qty));
}
break;
}
@@ -164,7 +164,7 @@ export const calculateAyam = (field: string, ctx: CalculationContext): void => {
setFieldValue('total_weight', tw);
// total_price = total_weight × unit_price
if (unitPrice > 0) {
setFieldValue('total_price', roundPrice(tw * unitPrice));
setFieldValue('total_price', tw * unitPrice);
}
}
break;
@@ -176,21 +176,21 @@ export const calculateAyam = (field: string, ctx: CalculationContext): void => {
}
// total_price = total_weight × unit_price
if (unitPrice > 0 && totalWeight > 0) {
setFieldValue('total_price', roundPrice(totalWeight * unitPrice));
setFieldValue('total_price', totalWeight * unitPrice);
}
break;
}
case 'unit_price': {
// total_price = total_weight × unit_price
if (unitPrice > 0 && totalWeight > 0) {
setFieldValue('total_price', roundPrice(totalWeight * unitPrice));
setFieldValue('total_price', totalWeight * unitPrice);
}
break;
}
case 'total_price': {
// unit_price = total_price / total_weight
if (totalPrice > 0 && totalWeight > 0) {
setFieldValue('unit_price', roundPrice(totalPrice / totalWeight));
setFieldValue('unit_price', totalPrice / totalWeight);
}
break;
}
@@ -223,7 +223,8 @@ export const calculateTelurPeti = (
// Helper untuk menghitung dan set unit_price = total_price / total_weight
const updateUnitPrice = (tp: number, tw: number) => {
if (tw > 0 && tp > 0) {
setFieldValue('unit_price', roundPrice(tp / tw));
const unitPrice = tp / tw;
setFieldValue('unit_price', unitPrice);
}
};
@@ -232,7 +233,7 @@ export const calculateTelurPeti = (
// Recalculate total_price = (price_per_convertion × total_peti) + price_sisa_berat
if (pricePerConvertion > 0 && totalPeti > 0) {
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
setFieldValue('total_price', roundPrice(totalPrice));
setFieldValue('total_price', totalPrice);
// Recalculate unit_price = total_price / total_weight
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
updateUnitPrice(totalPrice, totalWeight);
@@ -253,8 +254,8 @@ export const calculateTelurPeti = (
// Recalculate total_price = (price_per_convertion × total_peti) + price_sisa_berat
if (pricePerConvertion > 0 && totalPeti > 0) {
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
setFieldValue('total_price', roundPrice(totalPrice));
// Recalculate unit_price = total_price / total_weight
setFieldValue('total_price', totalPrice);
// Recalculate unit_price = total_price / totalWeight
updateUnitPrice(totalPrice, totalWeight);
}
break;
@@ -263,7 +264,7 @@ export const calculateTelurPeti = (
// Recalculate total_price
if (pricePerConvertion > 0 && totalPeti > 0) {
const totalPrice = pricePerConvertion * totalPeti + priceSisaBerat;
setFieldValue('total_price', roundPrice(totalPrice));
setFieldValue('total_price', totalPrice);
// Recalculate unit_price = total_price / total_weight
const totalWeight = weightPerConvertion * totalPeti + sisaBerat;
updateUnitPrice(totalPrice, totalWeight);
@@ -306,7 +307,7 @@ export const calculateTelurPeti = (
if (totalPeti > 0 && totalPrice > priceSisaBerat) {
setFieldValue(
'price_per_convertion',
roundPrice((totalPrice - priceSisaBerat) / totalPeti)
(totalPrice - priceSisaBerat) / totalPeti
);
}
// Update unit_price = total_price / total_weight
@@ -314,6 +315,15 @@ export const calculateTelurPeti = (
updateUnitPrice(totalPrice, totalWeight);
break;
}
case 'qty':
// Recalculate avg_weight = total_weight / qty
if (qty > 0 && values.total_weight) {
setFieldValue(
'avg_weight',
preciseWeight(Number(values.total_weight) / qty)
);
}
break;
}
};
@@ -341,10 +351,7 @@ export const calculateTelurKg = (
}
// total_price = total_weight × unit_price
if (pricePerConvertion > 0 && totalWeight > 0) {
setFieldValue(
'total_price',
roundPrice(totalWeight * pricePerConvertion)
);
setFieldValue('total_price', totalWeight * pricePerConvertion);
setFieldValue('unit_price', pricePerConvertion);
}
break;
@@ -352,10 +359,7 @@ export const calculateTelurKg = (
case 'price_per_convertion': {
// total_price = total_weight × price_per_convertion
if (pricePerConvertion > 0 && totalWeight > 0) {
setFieldValue(
'total_price',
roundPrice(totalWeight * pricePerConvertion)
);
setFieldValue('total_price', totalWeight * pricePerConvertion);
setFieldValue('unit_price', pricePerConvertion);
}
break;
@@ -363,11 +367,8 @@ export const calculateTelurKg = (
case 'total_price': {
// unit_price = total_price / total_weight
if (totalPrice > 0 && totalWeight > 0) {
setFieldValue('unit_price', roundPrice(totalPrice / totalWeight));
setFieldValue(
'price_per_convertion',
roundPrice(totalPrice / totalWeight)
);
setFieldValue('unit_price', totalPrice / totalWeight);
setFieldValue('price_per_convertion', totalPrice / totalWeight);
}
break;
}
@@ -376,13 +377,11 @@ export const calculateTelurKg = (
/**
* TELUR + QTY Workaround:
* - User inputs: qty, avg_weight, price_per_qty (harga per butir)
* - User inputs: qty, avg_weight, unit_price (harga per butir)
* - FE calculates:
* - total_weight = avg_weight × qty
* - total_price = qty × price_per_qty
* - unit_price = total_price / total_weight (normalisasi untuk BE)
* - Kirim convertion_unit: "KG" karena BE tidak support "QTY"
* - BE akan hitung: total_price = total_weight × unit_price (hasil sama)
* - total_price = qty × unit_price
* - price_per_qty = total_price / total_weight (harga per kg)
*/
export const calculateTelurQty = (
field: string,
@@ -403,13 +402,13 @@ export const calculateTelurQty = (
if (avgWeight > 0 && qty > 0) {
const tw = roundWeight(avgWeight * qty);
setFieldValue('total_weight', tw);
// total_price = qty × price_per_qty
if (pricePerQty > 0) {
const tp = roundPrice(qty * pricePerQty);
// total_price = qty × unit_price
if (unitPrice > 0) {
const tp = qty * unitPrice;
setFieldValue('total_price', tp);
// unit_price = total_price / total_weight (untuk BE)
// price_per_qty = total_price / total_weight
if (tw > 0) {
setFieldValue('unit_price', roundPrice(tp / tw));
setFieldValue('price_per_qty', tp / tw);
}
}
}
@@ -419,44 +418,47 @@ export const calculateTelurQty = (
// avg_weight = total_weight / qty
if (totalWeight > 0 && qty > 0) {
setFieldValue('avg_weight', preciseWeight(totalWeight / qty));
// Recalculate total_price jika ada unit_price
// Recalculate total_price jika ada harga per butir
if (unitPrice > 0) {
setFieldValue('total_price', roundPrice(totalWeight * unitPrice));
setFieldValue('total_price', qty * unitPrice);
}
}
break;
}
case 'price_per_qty': {
// total_price = qty × price_per_qty
if (pricePerQty > 0 && qty > 0) {
const tp = roundPrice(qty * pricePerQty);
// total_price = total_weight × price_per_qty
if (pricePerQty > 0 && totalWeight > 0) {
const tp = totalWeight * pricePerQty;
setFieldValue('total_price', tp);
// unit_price = total_price / total_weight (untuk BE)
if (totalWeight > 0) {
setFieldValue('unit_price', roundPrice(tp / totalWeight));
// unit_price = total_price / qty
if (qty > 0) {
setFieldValue('unit_price', tp / qty);
}
}
break;
}
case 'total_price': {
// price_per_qty = total_price / qty
// unit_price = total_price / qty
if (totalPrice > 0 && qty > 0) {
setFieldValue('price_per_qty', roundPrice(totalPrice / qty));
// unit_price = total_price / total_weight (untuk BE)
setFieldValue('unit_price', totalPrice / qty);
// price_per_qty = total_price / total_weight
if (totalWeight > 0) {
setFieldValue('unit_price', roundPrice(totalPrice / totalWeight));
setFieldValue('price_per_qty', totalPrice / totalWeight);
}
}
break;
}
case 'unit_price': {
// total_price = total_weight × unit_price
if (unitPrice > 0 && totalWeight > 0) {
setFieldValue('total_price', roundPrice(totalWeight * unitPrice));
// total_price = qty × unit_price
const newTotalPrice = qty * unitPrice;
if (unitPrice > 0 && qty > 0) {
setFieldValue('total_price', newTotalPrice);
}
// price_per_qty = total_price / qty
if (totalPrice > 0 && qty > 0) {
setFieldValue('price_per_qty', roundPrice(totalPrice / qty));
// price_per_qty = total_price / total_weight
if (newTotalPrice > 0 && totalWeight > 0) {
setFieldValue('price_per_qty', newTotalPrice / totalWeight);
}
break;
}
+61
View File
@@ -0,0 +1,61 @@
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Warehouse } from '@/types/api/master-data/warehouse';
export const getWarehouseScopeLabel = (
warehouse?: Warehouse | null
): string => {
if (!warehouse) {
return 'Gudang';
}
if (warehouse.type === 'KANDANG') {
return warehouse.kandang?.name
? `Kandang ${warehouse.kandang.name}`
: 'Gudang Kandang';
}
if (warehouse.type === 'LOKASI') {
return 'Gudang Farm';
}
return 'Gudang Area';
};
export const getProductWarehouseOptionLabel = (
productWarehouse?: ProductWarehouse | null
): string => {
if (!productWarehouse) {
return '';
}
const productName = productWarehouse.product?.name || 'Produk';
const warehouseName = productWarehouse.warehouse?.name || 'Gudang';
const warehouseScope = getWarehouseScopeLabel(productWarehouse.warehouse);
return `${productName}${warehouseName} (${warehouseScope})`;
};
export const isProductWarehouseSelectableForKandang = (
productWarehouse: ProductWarehouse,
kandangId?: number | null
): boolean => {
const warehouse = productWarehouse.warehouse;
if (!warehouse) {
return false;
}
if (warehouse.type === 'LOKASI') {
return true;
}
if (warehouse.type === 'KANDANG') {
return (
Boolean(kandangId) &&
(warehouse.kandang?.id === kandangId ||
productWarehouse.project_flock_kandang?.kandang_id === kandangId)
);
}
return false;
};
@@ -0,0 +1,20 @@
import { BaseApiService } from '@/services/api/base';
import {
DailyChecklistKandang,
CreateDailyChecklistKandangPayload,
UpdateDailyChecklistKandangPayload,
} from '@/types/api/daily-checklist/kandang';
export class DailyChecklistKandangApiService extends BaseApiService<
DailyChecklistKandang,
CreateDailyChecklistKandangPayload,
UpdateDailyChecklistKandangPayload
> {
constructor(basePath: string = '/master-data/kandang-groups') {
super(basePath);
}
}
export const DailyChecklistKandangApi = new DailyChecklistKandangApiService(
'/master-data/kandang-groups'
);
+20 -6
View File
@@ -1,7 +1,6 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { Dashboard } from '@/types/api/dashboard/dashboard';
import { httpClientFetcher } from '@/services/http/client';
import { Dashboard, DashboardFilter } from '@/types/api/dashboard/dashboard';
class DashboardService extends BaseApiService<Dashboard, unknown, unknown> {
constructor(basePath: string) {
@@ -14,11 +13,26 @@ class DashboardService extends BaseApiService<Dashboard, unknown, unknown> {
* @returns Promise with BaseApiResponse containing DashboardProduction
*/
async getDashboardProductionFetcher(
endpoint: string
params: DashboardFilter
): Promise<BaseApiResponse<Dashboard> | undefined> {
return await httpClientFetcher<BaseApiResponse<Dashboard>>(
`${endpoint ? endpoint : this.basePath}`
);
return await this.customRequest<BaseApiResponse<Dashboard>>('', {
method: 'GET',
params: {
start_date: params.start_date || undefined,
end_date: params.end_date || undefined,
analysis_mode: params.analysis_mode || undefined,
location_ids: params.location_ids.length
? params.location_ids.toString()
: undefined,
flock_ids: params.flock_ids.length
? params.flock_ids.toString()
: undefined,
kandang_ids: params.kandang_ids.length
? params.kandang_ids.toString()
: undefined,
comparison_type: params.comparison_type || undefined,
},
});
}
}
+26
View File
@@ -12,6 +12,8 @@ import {
NextDayRecording,
} from '@/types/api/production/recording';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { httpClient } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
export const ProjectFlockKandangApi = new BaseApiService<
ProjectFlockKandang,
@@ -88,6 +90,30 @@ export class RecordingService extends BaseApiService<
}
);
}
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `recording-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
}
export const RecordingApi = new RecordingService('/production/recordings');
-1
View File
@@ -56,7 +56,6 @@ export class UniformityApiService extends BaseApiService<
): Promise<BaseApiResponse<UniformityDetail> | undefined> {
const formData = new FormData();
formData.append('date', payload.date);
formData.append('week', payload.week.toString());
formData.append(
'project_flock_kandang_id',
payload.project_flock_kandang_id.toString()
+1 -1
View File
@@ -154,7 +154,7 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
);
const updateFilter = useCallback(
<K extends keyof TExtra>(key: K, value: TExtra[K], resetPage = true) => {
<K extends keyof TExtra>(key: K, value: TExtra[K], resetPage = false) => {
dispatch({ type: 'UPDATE_FILTER', key, value, resetPage });
},
[dispatch]
+7
View File
@@ -9,6 +9,13 @@ export type RequestOptions<B = unknown> = {
auth?: AuthMode; // 'cookie' | 'bearer' | 'none'
token?: string; // required if auth === 'bearer'
timeoutMs?: number;
responseType?:
| 'arraybuffer'
| 'blob'
| 'document'
| 'json'
| 'text'
| 'stream';
};
export class HttpError extends Error {
+5 -1
View File
@@ -10,7 +10,10 @@ const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 10_000 });
axiosClient.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
if (
error.response?.status === 401 &&
error.config?.url !== '/sso/refresh'
) {
redirectToSSO();
}
@@ -37,6 +40,7 @@ export async function httpClient<T, B = unknown>(
data: opts.body,
timeout: opts.timeoutMs ?? 10_000,
withCredentials: isCookieAuth && !isBearerAuth,
responseType: opts.responseType,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(opts.headers ?? {}),
@@ -4,13 +4,16 @@ import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createChickinApprovalSlice } from '@/stores/production/chickin/slices/chickin-approval.slice';
import { ChickinApprovalSlice } from '@/stores/production/chickin/slices/chickin-approval.slice';
import { createChickinDeleteSlice } from '@/stores/production/chickin/slices/chickin-delete.slice';
import { ChickinDeleteSlice } from '@/stores/production/chickin/slices/chickin-delete.slice';
export type ChickinStore = ChickinApprovalSlice;
export type ChickinStore = ChickinApprovalSlice & ChickinDeleteSlice;
export const useChickinStore = create<ChickinStore>()(
devtools(
(...args) => ({
...createChickinApprovalSlice(...args),
...createChickinDeleteSlice(...args),
}),
{
name: 'ChickinStore',

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