Compare commits

..

563 Commits

Author SHA1 Message Date
randy-ar 359cdb2534 fix(FE): adding margin on data state skeleton 2026-01-30 11:24:42 +07:00
randy-ar 8a64780135 fix(FE): adding margin on data state skeleton 2026-01-30 11:20:47 +07:00
randy-ar 3b7836c8ba Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/debt-supplier 2026-01-30 11:03:04 +07:00
randy-ar ba1d462a0a fix(FE): adding skeleton state data on null or unfiltered 2026-01-30 11:02:18 +07:00
Adnan Zahir 352688054e Merge branch 'fix/adjustment-uniformity-ui' into 'development'
[FIX/FE] Pixel Perfect Uniformity UI

See merge request mbugroup/lti-web-client!279
2026-01-30 10:44:22 +07:00
rstubryan 06c4631ca5 refactor(FE): Refactor filter modal into form and adjust UI 2026-01-30 10:37:11 +07:00
rstubryan 2b6676b4eb Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/adjustment-uniformity-ui 2026-01-30 10:17:13 +07:00
rstubryan 14c208f494 refactor(FE): Add border class to UniformityChart cards 2026-01-30 10:15:07 +07:00
rstubryan 27ab373ebb refactor(FE): Add subtle border to UniformityChart cards 2026-01-30 10:14:13 +07:00
Rivaldi A N S ab4e9fbd39 Merge branch 'fix/refresh-session-issue' into 'development'
[FIX/FE] Refresh Session Issue

See merge request mbugroup/lti-web-client!281
2026-01-30 03:08:55 +00:00
ValdiANS dd586f07d2 fix: only refresh session when user data is available 2026-01-30 10:03:03 +07:00
rstubryan 9d70f94b33 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/adjustment-uniformity-ui 2026-01-30 09:58:34 +07:00
rstubryan 5ebeeeedb3 refactor(FE): Make UniformityBarChartSkeleton more responsive 2026-01-30 09:56:30 +07:00
rstubryan a9ff4579b0 refactor(FE): Adjust 2xl grid layout in UniformityChart 2026-01-30 09:46:10 +07:00
rstubryan a1fe08fdeb refactor(FE): Use text-base for UniformityChart headings 2026-01-30 09:38:38 +07:00
rstubryan fc81fa9ad3 refactor(FE): Fix bg class syntax in uniformity skeletons 2026-01-30 09:22:52 +07:00
randy-ar e980320d00 fix(FE): fixing report finance filter ui 2026-01-29 20:59:16 +07:00
randy-ar 5509f52464 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/debt-supplier 2026-01-29 20:19:25 +07:00
randy-ar 0ed6c246b1 fix(FE): fix badge and table pdf 2026-01-29 20:18:42 +07:00
randy-ar c7818cefbb fix(FE): adjust ui debt supplier pixel perfect figma 2026-01-29 18:24:43 +07:00
Rivaldi A N S b004877584 Merge branch 'fix/adjustment-transfer-stock-form' into 'development'
[HOTFIX/FE] Require Delivery Fields if Supplier Chosen

See merge request mbugroup/lti-web-client!280
2026-01-29 10:30:29 +00:00
rstubryan a51a020dfa refactor(FE): Replace Button with static icon tiles in skeletons 2026-01-29 17:07:56 +07:00
rstubryan a3670271de Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/adjustment-uniformity-ui 2026-01-29 16:54:09 +07:00
rstubryan adaac46236 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/adjustment-transfer-stock-form 2026-01-29 16:53:47 +07:00
Rivaldi A N S 49e843f3b2 Merge branch 'fix/dashboard' into 'development'
[FIX/FE] Fixing UI Dashboard

See merge request mbugroup/lti-web-client!277
2026-01-29 09:38:30 +00:00
randy-ar b9ed7df063 fix(FE): fixing rounded value 2026-01-29 16:33:02 +07:00
rstubryan 60d5551dff fix(FE): Require delivery fields if supplier chosen 2026-01-29 16:17:57 +07:00
rstubryan db33247d6c refactor(FE): Use English copy for bulk reject modal 2026-01-29 15:41:05 +07:00
rstubryan aab8e0c5ce refactor(FE): Remove unused imports and accessorKey props 2026-01-29 15:37:53 +07:00
rstubryan 6c6634fa1d refactor(FE): Rename column header 'No' to 'Number' 2026-01-29 15:35:32 +07:00
rstubryan e93a9c8011 refactor(FE): Add divider above Next button in UniformityForm 2026-01-29 15:33:00 +07:00
rstubryan b8031448ff refactor(FE): Adjust checkbox alignment and column padding 2026-01-29 15:24:30 +07:00
randy-ar c63df91e08 fix(FE): fixing rounded value 2026-01-29 15:10:18 +07:00
rstubryan 0e43957e6e refactor(FE): Replace Badge with StatusBadge in uniformity views 2026-01-29 14:58:09 +07:00
rstubryan dada6a542f refactor(FE): Add badge color helpers for status and weight 2026-01-29 14:57:25 +07:00
randy-ar b305d43ce6 fix(FE): fixing classname using dynamic value 2026-01-29 14:56:52 +07:00
randy-ar 8d5b195691 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/dashboard 2026-01-29 14:51:40 +07:00
randy-ar 6c2baca807 fix(FE): fixing classname using dynamic value 2026-01-29 14:50:16 +07:00
rstubryan d9a1d340bb refactor(FE): Reduce vertical spacing in Uniformity components 2026-01-29 14:32:52 +07:00
rstubryan 8511a75842 refactor(FE): Refactor uniformity drawer styles and layout 2026-01-29 14:24:58 +07:00
rstubryan 4452b6fd03 refactor(FE): Add spacing to UniformityTableSkeleton 2026-01-29 14:10:36 +07:00
rstubryan 1ed83351e0 refacto(FE): Refine UniformityTableSkeleton styles and icon 2026-01-29 14:09:38 +07:00
rstubryan 9817864c3d refactor(FE): Reduce UniformityBarChartSkeleton min height 2026-01-29 14:04:35 +07:00
rstubryan 564d6d0da1 refactor(FE): Add wrapperContent and refactor uniformity charts 2026-01-29 13:58:42 +07:00
rstubryan 2c8160f816 refactor(FE): Add padding to UniformityChart card bodies 2026-01-29 12:00:10 +07:00
rstubryan 23402370b8 refactor(FE): Add rounded corners to uniformity components 2026-01-29 11:57:44 +07:00
rstubryan e3d929435a refactor(FE): Refine empty-state styling in uniformity skeletons 2026-01-29 11:53:06 +07:00
rstubryan ef3797e724 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/adjustment-uniformity-ui 2026-01-29 11:31:33 +07:00
Rivaldi A N S cddb1422f6 Merge branch 'fix/adjustment-transfer-stock-form' into 'development'
[FIX/FE] Adjustment Transfer Stock Form and Adjustment Select Option

See merge request mbugroup/lti-web-client!278
2026-01-29 04:30:47 +00:00
rstubryan f28aeda74c refactor(FE): Move Error List Alert below form fields 2026-01-29 11:29:19 +07:00
rstubryan 75dfd96934 refactor(FE): Replace Badge with simple span and rename adornment prop 2026-01-29 11:09:30 +07:00
rstubryan 079d69dffb refactor(FE): Simplify SelectInput control styling 2026-01-29 11:05:06 +07:00
rstubryan 711536975c refactor(FE): Apply innerProps and className to CustomControl 2026-01-29 10:58:49 +07:00
rstubryan 97bf785fe9 refactor(FE): Support inputPrefix/inputSuffix on SelectInput 2026-01-29 10:27:47 +07:00
ValdiANS dce913815e chore: adjust styling in Dashboard related components 2026-01-29 10:23:58 +07:00
rstubryan 737d8e943c refactor(FE): Make delivery fields nullable and optional 2026-01-29 09:48:43 +07:00
rstubryan 0bdf27de2c refactor(FE): Make delivery fields optional in movement types 2026-01-29 09:47:30 +07:00
randy-ar 9cff99cba9 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/dashboard 2026-01-28 17:56:35 +07:00
randy-ar 46cfc2539e fix(FE): remove unused import 2026-01-28 17:55:57 +07:00
randy-ar 34f93f8dcc fix(FE): refactor UI Dashboard pixel perfect figma 2026-01-28 17:54:55 +07:00
rstubryan 1a800a1157 refactor(FE): Refine drawer header and uniformity form styles 2026-01-28 16:14:26 +07:00
rstubryan 3c96855b86 refactor(FE): Replace Card with section in UniformityTable 2026-01-28 15:59:18 +07:00
rstubryan 607bf28121 refactor(FE): Refactor Uniformity header and add filter badge 2026-01-28 15:10:36 +07:00
rstubryan d8361be28f refactor(FE): Replace UniformityTable with Uniformity page 2026-01-28 14:45:48 +07:00
Rivaldi A N S 031d51947a Merge branch 'fix/error-list-position' into 'development'
[FIX/FE] Adjust Error List Position to Bottom

See merge request mbugroup/lti-web-client!276
2026-01-28 07:27:41 +00:00
rstubryan 554037bfe5 refactor(FE): Move expense form error alerts below fields 2026-01-28 14:20:41 +07:00
rstubryan 9f93200bd5 refactor(FE): Move form error alerts to bottom of forms 2026-01-28 14:14:44 +07:00
Rivaldi A N S 70fcbf795b Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Refactor Prefix and Suffix Size Based on Latest Field Input

See merge request mbugroup/lti-web-client!275
2026-01-28 06:59:03 +00:00
rstubryan 406befc21b refactor(FE): Refine input component styles and error UI 2026-01-28 13:43:46 +07:00
Rivaldi A N S 558f219e8b Merge branch 'dev/hotfix/restu' into 'development'
[HOTFIX/FE] Fix Edit Realization Restriction Issue Based on Approval Line

See merge request mbugroup/lti-web-client!273
2026-01-28 06:07:37 +00:00
rstubryan 8671f37ada refactor(FE): Treat kandang_id as optional and use expense id 2026-01-28 12:19:36 +07:00
rstubryan 15dc04bb95 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-28 11:58:13 +07:00
Rivaldi A N S f801378ad2 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!272
2026-01-28 04:52:44 +00:00
rstubryan 73e8697097 refactor(FE): Allow editing at approval steps 5 and 6 2026-01-28 11:51:07 +07:00
ValdiANS b3f6f36c00 fix: reset employee when changing phase 2026-01-28 11:50:55 +07:00
Rivaldi A N S b9c4f44e3f Merge branch 'feat/create-pdf-component' into 'development'
[FEAT/FE] Create PDF Table Component

See merge request mbugroup/lti-web-client!270
2026-01-28 04:39:33 +00:00
rstubryan 5a7b750203 chore(FE): Remove PdfContainer component 2026-01-28 11:24:22 +07:00
rstubryan bb30b6cb21 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/create-pdf-component 2026-01-28 11:22:59 +07:00
rstubryan 0cc857378f refactor(FE): Add icons to PurchasesPerSupplierTab controls 2026-01-28 11:18:51 +07:00
rstubryan e68e5a6d51 refactor(FE): Refine Purchases per Supplier styles and page size 2026-01-28 11:10:37 +07:00
rstubryan c20b1c5942 refactor(FE): Refactor PDF exports to use PdfTable 2026-01-28 11:04:06 +07:00
rstubryan 0a5efbe383 refactor(FE): Omit right border for last PDF table column 2026-01-28 11:03:17 +07:00
Rivaldi A N S 2e769b234d Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!269
2026-01-28 03:30:50 +00:00
ValdiANS f87736154a fix: only fetch initial value if the modal action is action for form 2026-01-28 10:29:15 +07:00
rstubryan ca0d379c2c refactor(FE): Rename initialBalanceRow to firstRow 2026-01-28 10:00:01 +07:00
Rivaldi A N S 87492876e5 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!267
2026-01-28 02:56:58 +00:00
rstubryan 3d8d0d9e4d feat(FE): Add PDF table index exports 2026-01-28 09:50:13 +07:00
rstubryan 956f1ce500 refactor(FE): Refactor PdfTable to use PdfThead/PdfTbody/PdfTfoot 2026-01-28 09:49:26 +07:00
ValdiANS a11b648bf9 Merge branch 'development' into fix/daily-checklist 2026-01-28 09:42:16 +07:00
ValdiANS 31581e963f fix: set empty daily checklist phase if phase is not selected 2026-01-28 09:39:32 +07:00
rstubryan c63882fbd7 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/create-pdf-component 2026-01-28 09:14:49 +07:00
Rivaldi A N S ab84f59929 Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!266
2026-01-27 12:00:09 +00:00
ValdiANS 5fe0236686 feat: implement export to excel 2026-01-27 18:58:47 +07:00
ValdiANS 8f6597e7df feat: add exportToExcel method 2026-01-27 18:58:03 +07:00
Rivaldi A N S c4f4775a48 Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!265
2026-01-27 11:21:43 +00:00
ValdiANS 81e5a180ba feat: create ProjectFlockMaxQuantity type 2026-01-27 18:18:15 +07:00
ValdiANS 1d65cf0d08 feat: create getMaxTargetQty and getMappedFlockKandangsMaxTargetQty method 2026-01-27 18:18:06 +07:00
ValdiANS 4ef5ee7142 fix: get real max quantity in target project flock kandang 2026-01-27 18:17:50 +07:00
ValdiANS 19a90c9045 feat: add search input 2026-01-27 18:17:34 +07:00
ValdiANS fcbb34624d fix: get real max quantity in target project flock kandang 2026-01-27 18:17:13 +07:00
ValdiANS 68ccb66e5c chore: update TextInput styling 2026-01-27 18:16:41 +07:00
ValdiANS a094eb94a5 chore: render see more button conditionally 2026-01-27 18:16:34 +07:00
rstubryan 4db2bc187a Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/create-pdf-component 2026-01-27 18:15:31 +07:00
Rivaldi A N S e8ff69b43f Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!264
2026-01-27 10:06:20 +00:00
ValdiANS b03a47ddc6 refactor: delete unnecessary page and component 2026-01-27 17:03:06 +07:00
Rivaldi A N S 62a78b8619 Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Change Required Field to Optional on Delivery Supplier Field (Transfer Stock)

See merge request mbugroup/lti-web-client!263
2026-01-27 10:02:42 +00:00
ValdiANS 7f43ef6c56 chore: adjust Approvals type 2026-01-27 16:59:32 +07:00
ValdiANS 51ad37cc48 feat: add getApprovalLineHistory method 2026-01-27 16:59:19 +07:00
ValdiANS 9a00d4b98e refactor: adjust APPROVAL_WORKFLOWS structure 2026-01-27 16:59:10 +07:00
ValdiANS 6528899aaf chore: adjust maxTotalQuantity error message 2026-01-27 16:58:48 +07:00
ValdiANS f94c8ba799 chore: adjust detail link and add transfer_number column 2026-01-27 16:58:37 +07:00
ValdiANS df4b513739 chore: add required mark 2026-01-27 16:58:15 +07:00
ValdiANS 50477d0850 feat: create TransferToLayingDetailModal component 2026-01-27 16:57:46 +07:00
ValdiANS 79f0e2b7b7 chore: add loading state for table 2026-01-27 16:57:35 +07:00
ValdiANS 63c3818766 chore: update TextInput styling 2026-01-27 16:57:18 +07:00
ValdiANS f6a360ee2b chore: update TextArea styling 2026-01-27 16:57:05 +07:00
ValdiANS aa39478318 feat: create ApprovalStepsV2 component 2026-01-27 16:56:57 +07:00
ValdiANS 02fbd677fc chore: add readOnly prop and adjust SelectInput styling 2026-01-27 16:54:44 +07:00
ValdiANS 98608576b9 chore: adjust DateInput styling 2026-01-27 16:53:17 +07:00
ValdiANS 80d9dd689a feat: add TransferToLayingDetailModal component in Transfer to Laying page 2026-01-27 16:53:04 +07:00
ValdiANS 0d1907f729 chore: update base content and warning color 2026-01-27 16:52:46 +07:00
rstubryan 3aab90d3d6 refactor(FE): Remove min validation on supplier_id 2026-01-27 16:27:09 +07:00
rstubryan 6f96f20b49 refactor(FE): Format PdfTfoot props and inline Text 2026-01-27 16:24:43 +07:00
rstubryan 142ce7fe3a feat(FE): Add PDF table components for report export 2026-01-27 16:23:29 +07:00
Rivaldi A N S b7085f5d2a Merge branch 'fix/adjustment-transfer-stock' into 'development'
[FIX/FE] Adjustment Transfer Stock's Issues and Purchase Table

See merge request mbugroup/lti-web-client!262
2026-01-27 06:44:36 +00:00
rstubryan 628facb23d Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-27 13:23:51 +07:00
rstubryan 27c112e479 refactor(FE): Use index-only keys for product/delivery rows 2026-01-27 12:01:53 +07:00
Rivaldi A N S b19340536a Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!260
2026-01-26 15:30:22 +00:00
ValdiANS 163d225dba fix: set limit to 100 when fetching phase activities 2026-01-26 22:28:49 +07:00
Rivaldi A N S f07db1be7a Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!259
2026-01-26 15:23:25 +00:00
ValdiANS 4323040bd3 feat: use TransferToLayingConfirmationModal and remove unnecessary code 2026-01-26 22:21:11 +07:00
ValdiANS 97a753133e chore: adjust secondayButton onClick 2026-01-26 22:20:19 +07:00
ValdiANS 273810804d chore: adjust secondayButton onClick 2026-01-26 22:20:03 +07:00
ValdiANS 2be417ac0a chore: set error in input and use TransferToLayingConfirmationModal component 2026-01-26 21:23:55 +07:00
ValdiANS f98e9d6930 feat: create TransferToLayingConfirmationModal component 2026-01-26 21:22:57 +07:00
rstubryan 4c336f81c7 refactor(FE): Allow supplier to be optional in movement form 2026-01-26 21:21:40 +07:00
ValdiANS 1389cb7ed6 chore: render button only if primary or secondary button is used 2026-01-26 20:57:50 +07:00
ValdiANS a7958166bf chore: change circle success color 2026-01-26 20:57:27 +07:00
ValdiANS 34f01abb32 chore: add new props (withPagination, getRowCanExpand, renderSubComponent, expanded, and getSubRows) 2026-01-26 20:57:05 +07:00
ValdiANS 66c537ec10 chore: change loading text to loading spinner 2026-01-26 20:56:12 +07:00
rstubryan 2847f50bf7 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-26 17:32:49 +07:00
rstubryan 7b4d69b0b0 refactor(FE): Clarify duplicate recording error message 2026-01-26 16:48:16 +07:00
Rivaldi A N S 37ab614a97 Merge branch 'fix/finance' into 'development'
[FIX/FE] Adding Select Input Injection Type for Negative or Positive Value

See merge request mbugroup/lti-web-client!257
2026-01-26 09:16:38 +00:00
randy-ar 7c73e8e5c6 fix(FE): fixing negative value in index table 2026-01-26 16:13:43 +07:00
randy-ar 85f4a5deaa fix(FE): adding select injection type for positive and negative value 2026-01-26 16:08:23 +07:00
rstubryan 4011d26193 refactor(FE): Make approval and action buttons responsive 2026-01-26 13:45:32 +07:00
rstubryan afb0c40fd2 refactor(FE): Add Status Approval column to PurchaseTable 2026-01-26 13:37:06 +07:00
rstubryan 2a03eae8a2 refactor(FE): Remove section padding on small+ screens 2026-01-26 13:35:45 +07:00
rstubryan e9238e2bb5 feat(FE): Persist search in UI store and reset on exit 2026-01-26 11:45:57 +07:00
Rivaldi A N S 40eaa729ef Merge branch 'fix/adjustment-recording-and-uniformity' into 'development'
[FIX/FE] Adjustment Recording, Uniformity and Transfer Stock

See merge request mbugroup/lti-web-client!256
2026-01-26 04:09:42 +00:00
rstubryan 47e51e0105 refactor(FE): Remove redundant input bg class from TextInputs 2026-01-26 09:51:37 +07:00
rstubryan 56326cc8d2 refactor(FE): Disable product select until kandang chosen 2026-01-26 09:41:50 +07:00
rstubryan 860a2d988e Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-26 09:26:34 +07:00
Rivaldi A N S 79701fbcfe Merge branch 'hotfix/finance' into 'development'
[HOTFIX/FE] Fixing Injection Balance Negative value

See merge request mbugroup/lti-web-client!255
2026-01-26 01:44:07 +00:00
rstubryan 8b1e43cdb9 refactor(FE): Set week using chick_in_date and earliest record 2026-01-25 18:00:53 +07:00
rstubryan 4768a7d6fd Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-25 17:46:05 +07:00
randy-ar caba77d871 fix(FE): fixing bank undefined in detail page 2026-01-25 14:11:47 +07:00
randy-ar 37c0c1cf42 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into hotfix/finance 2026-01-24 14:53:57 +07:00
randy-ar 1d06c6f02a fix(FE): fixing injection payload negative not send properly 2026-01-24 14:52:54 +07:00
Rivaldi A N S ae206b9426 Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!252
2026-01-24 07:03:07 +00:00
ValdiANS d3d3859021 fix: set different key in useSWR for getting flockSourceKandangsAvailability 2026-01-24 14:01:39 +07:00
Rivaldi A N S 447ef81871 Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!251
2026-01-24 06:35:57 +00:00
ValdiANS 2507f733fb chore: fetch flock source kandang available qty in TransferToLayingFormModal 2026-01-24 13:35:09 +07:00
rstubryan 2d6e8480f5 refactor(FE): Show detailed stock usage and remove limit check 2026-01-24 13:27:17 +07:00
Rivaldi A N S 9d0fdb346d Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!250
2026-01-24 06:11:43 +00:00
ValdiANS 08f3372b46 chore: access available_qty optionally 2026-01-24 13:10:51 +07:00
rstubryan 6307cdc0dc refactor(FE): Send selected record date to next-day API 2026-01-24 12:53:54 +07:00
rstubryan 52213fc8c4 refactor(FE): Add optional location and flock ids 2026-01-24 12:51:01 +07:00
Rivaldi A N S 826e83b025 Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!247
2026-01-24 05:50:00 +00:00
ValdiANS c300bdcb0f fix: adjust wording 2026-01-24 12:49:16 +07:00
Rivaldi A N S 84a7b0e50f Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!246
2026-01-24 05:47:12 +00:00
ValdiANS 839bf4daac fix: get flock source data if ID exist in query 2026-01-24 12:45:38 +07:00
ValdiANS 38955b96de chore: show Bank name conditionally 2026-01-24 12:29:23 +07:00
Rivaldi A N S 542992eaab Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer To Laying

See merge request mbugroup/lti-web-client!245
2026-01-24 04:24:04 +00:00
ValdiANS 982d0294b6 Merge branch 'development' into fix/transfer-to-laying 2026-01-24 11:22:48 +07:00
ValdiANS 42ebf1015f chore: adjust getFilledTransferToLayingFormInitialValues function to set max quantity for flock destination kandang 2026-01-24 11:21:31 +07:00
ValdiANS 4991b1160f chore: remove unnecessary code and adjust edit link 2026-01-24 11:21:02 +07:00
ValdiANS 71a430c99c feat: create TransferToLayingFormModal component 2026-01-24 11:20:24 +07:00
ValdiANS db1e224c3b chore: adjust TextInput styling 2026-01-24 11:20:11 +07:00
ValdiANS bb74b90790 chore: adjust TextArea styling 2026-01-24 11:19:58 +07:00
ValdiANS a890ed571b chore: adjust SelectInputRadio styling 2026-01-24 11:19:50 +07:00
ValdiANS 2d81b0dfba chore: adjust DateInput styling 2026-01-24 11:19:41 +07:00
ValdiANS ab390ab461 feat: add className prop 2026-01-24 11:19:33 +07:00
ValdiANS 335b254a60 feat: add onBackdropClick and position prop 2026-01-24 11:19:24 +07:00
ValdiANS 5e53f8764e chore: update href type 2026-01-24 11:19:04 +07:00
ValdiANS f0051b58bb feat: add TransferToLayingFormModal in Transfer To Laying page 2026-01-24 11:18:52 +07:00
rstubryan 8c73a8f61a refactor(FE): Include selected record date in next-day API 2026-01-24 11:17:13 +07:00
Rivaldi A N S 6636648813 Merge branch 'fix/purchase-limitation' into 'development'
[FIX/FE] Fix Limitation on useSelect (Purchase Request)

See merge request mbugroup/lti-web-client!242
2026-01-24 02:13:07 +00:00
rstubryan 151af5707d refactor(FE): Enable supplier select infinite loading 2026-01-24 09:10:40 +07:00
rstubryan 716f064858 refactor(FE): Limit supplier useSelect results to 100 2026-01-24 09:03:19 +07:00
Rivaldi A N S be87bc7c1d Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Hotfix Issues on Transfer Stock, Customer Control Payment and Uniformity

See merge request mbugroup/lti-web-client!241
2026-01-24 01:26:39 +00:00
ValdiANS 111531b803 Merge branch 'development' into fix/transfer-to-laying 2026-01-23 23:07:51 +07:00
ValdiANS d59cf359ee chore: adjust TransferToLayingsTable component styling 2026-01-23 23:05:09 +07:00
ValdiANS 2f7ae0ae66 feat: create TransferToLayingFilter type 2026-01-23 23:04:18 +07:00
ValdiANS 8cc7f2f526 feat: add findMenuPath helper function 2026-01-23 23:04:04 +07:00
ValdiANS 25074edaa1 feat: create tailwind.css file 2026-01-23 23:03:46 +07:00
ValdiANS 0b1349ca8d feat: create TransferToLayingFilterModal component 2026-01-23 23:03:34 +07:00
ValdiANS 6c70dc93ce chore: pass the rest of the props to ConfirmationModal 2026-01-23 23:03:13 +07:00
ValdiANS ed3d525c06 chore: adjust ConfirmationModal component styling 2026-01-23 23:02:49 +07:00
ValdiANS af9c4bbdb9 chore: adjust SelectInputCheckbox styling 2026-01-23 23:02:25 +07:00
ValdiANS 5a88718454 chore: adjust SelectInput styling 2026-01-23 23:02:12 +07:00
ValdiANS d1a0cdc1b9 chore: adjust DateInput styling 2026-01-23 23:01:59 +07:00
ValdiANS 196db657e8 feat: create StatusBadge component 2026-01-23 23:01:43 +07:00
ValdiANS 51c3277b6c feat: add selectedBodyRowClassName class name 2026-01-23 23:01:33 +07:00
ValdiANS abc35314a0 chore: adjust Navbar styling and add breadcrumb to navbar 2026-01-23 23:01:07 +07:00
ValdiANS 146a63fc70 chore: remove unnecessary code and remove title from Navbar prop 2026-01-23 23:00:44 +07:00
ValdiANS d46652cb68 feat: add Breadcrumb component 2026-01-23 23:00:15 +07:00
ValdiANS b46f06a739 chore: remove padding 2026-01-23 22:59:54 +07:00
ValdiANS 6cc5e5e931 chore: adjust color variable and add shadow-button-soft custom shadow 2026-01-23 22:59:44 +07:00
rstubryan 7b82888aa6 refactor(FE): Update styling and imports in CustomerPaymentTab 2026-01-23 21:53:59 +07:00
rstubryan 762fb08568 refactor(FE): Replace hardcoded blue with bg-primary 2026-01-23 21:48:57 +07:00
rstubryan b7c0a80a04 refactor(FE): Validate end_date is not before start_date 2026-01-23 21:45:09 +07:00
rstubryan 143674533a refactor(FE): Remove redundant ID fields and require filters and update
date labels to 'Tanggal mulai' and 'Tanggal akhir
2026-01-23 21:40:54 +07:00
rstubryan b046b64ed2 refactor(FE): Add Formik-based filter with validation 2026-01-23 21:32:10 +07:00
rstubryan 9f6fec5a3c refactor(FE): Set uniformity week from recording data 2026-01-23 20:55:48 +07:00
rstubryan e386d2a389 chore(FE-Cleanup): Clean up unused code and add memoization 2026-01-23 18:12:44 +07:00
rstubryan cdef3e797e Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-23 17:34:10 +07:00
rstubryan 6af2609f44 refactor(FE): Use useSelect to fetch product warehouses 2026-01-23 16:38:34 +07:00
rstubryan 6f5540eb91 refactor(FE): Reset deliveries when products change 2026-01-23 16:26:25 +07:00
rstubryan 5c286128e4 refactor(FE): Exclude falsy product_id in product filter 2026-01-23 16:13:36 +07:00
rstubryan df875eda1d refactor(FE): Exclude selected products from product options 2026-01-23 16:05:59 +07:00
Rivaldi A N S ae2b27521e Merge branch 'fix/production-result' into 'development'
[FIX/FE] Production Result

See merge request mbugroup/lti-web-client!240
2026-01-23 09:01:27 +00:00
ValdiANS d026a3b5ae chore: round export number to excel 2026-01-23 15:59:46 +07:00
ValdiANS ed9a8021c1 feat: add safeRound helper function 2026-01-23 15:59:31 +07:00
rstubryan 31d9c5e38d Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-23 15:37:32 +07:00
Rivaldi A N S e749faedca Merge branch 'fix/production-result' into 'development'
[FIX/FE] Production Result

See merge request mbugroup/lti-web-client!239
2026-01-23 08:37:32 +00:00
rstubryan 27c696c797 refactor(FE): Use filter to remove products and deliveries 2026-01-23 15:37:06 +07:00
ValdiANS 28b58d9cac fix: fix exportProductionResultToExcel method 2026-01-23 15:36:16 +07:00
rstubryan 1d0d42dc16 refactor(FE): Include products in MovementForm effect deps 2026-01-23 13:45:55 +07:00
rstubryan 1b3dd34add refactor(FE): Filter products by available stock and format qty 2026-01-23 13:21:12 +07:00
rstubryan 1d29f62bf2 refactor(FE): Fix delivery dependency tracking and init 2026-01-23 13:10:54 +07:00
rstubryan e35f857057 fix(FE): Include deliveries in deps for cost handlers 2026-01-23 12:58:33 +07:00
rstubryan bcc2f71623 refactor(FE): Manage product and delivery quantity error toasts 2026-01-23 12:52:54 +07:00
rstubryan 359f0f7b01 refactor(FE): Move EKSPEDISI flag to supplier select 2026-01-23 11:10:08 +07:00
rstubryan 6a9f672d27 refactor(FE): Reduce font weight and size for payment badge 2026-01-23 11:05:42 +07:00
rstubryan 8c976b6d0b refactor(FE): Center PDF status and refine payment badge styling 2026-01-23 10:54:15 +07:00
Rivaldi A N S e00a81cebb Merge branch 'hotfix/finance' into 'development'
[HOTFIX/FE] Fixing Form Component State

See merge request mbugroup/lti-web-client!238
2026-01-23 03:36:49 +00:00
rstubryan a82860cb68 refactor(FE): Add initial balance row and normalize empty values 2026-01-23 10:34:53 +07:00
randy-ar 52a18dac24 fix(FE): fixing submit button disable and implement store search filter 2026-01-23 10:30:50 +07:00
randy-ar c2272ee5e0 hotfix(FE): fixing select input edit supplier in edit transaction 2026-01-22 16:20:40 +07:00
Rivaldi A N S c012f39a38 Merge branch 'fix/dashboard' into 'development'
[FIX/FE] Fixing Store State Filter Dashboard

See merge request mbugroup/lti-web-client!235
2026-01-22 09:06:26 +00:00
Rivaldi A N S 0f72a14fde Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Add Tooltip, Support Comma on Recording Egg Values (Total Berat) and Add Global State Search for Table

See merge request mbugroup/lti-web-client!234
2026-01-22 09:05:40 +00:00
randy-ar 1c35c7db32 fix(FE): fixing filter store state on reset 2026-01-22 16:01:39 +07:00
rstubryan 1f6ce36976 refactor(FE): Move table slice into UI store and persist search 2026-01-22 15:58:56 +07:00
rstubryan 756701722a feat(FE): Persist Recording search across navigation 2026-01-22 15:24:04 +07:00
rstubryan 8e48c4d7cf Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-22 14:48:38 +07:00
Rivaldi A N S 282f651d96 Merge branch 'hotfix/marketing' into 'development'
[HOTFIX/FE] Sales Product Calculation

See merge request mbugroup/lti-web-client!233
2026-01-22 07:48:32 +00:00
rstubryan 49abb129e3 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-22 14:48:13 +07:00
rstubryan 9d6148e877 refactor(FE): Add tooltip and allow 3-decimal weight input 2026-01-22 14:46:38 +07:00
randy-ar d914eb86f2 hotfix: fix sales calculation 2026-01-22 14:43:07 +07:00
randy-ar e8a6bf05c3 hotfix: fix sales calculation 2026-01-22 14:38:34 +07:00
randy-ar ffa0b23b82 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into hotfix/marketing 2026-01-22 14:20:42 +07:00
rstubryan 49e9e958fa refactor(FE): Use local state for record date and disable
reinitialization
2026-01-22 14:20:16 +07:00
randy-ar b17ccd502e hotfix: fix sales calculation 2026-01-22 14:19:48 +07:00
Rivaldi A N S 1a7b969c3f Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Fix Aging/Umur on Closing Detail (Penjualan's Tab)

See merge request mbugroup/lti-web-client!232
2026-01-22 06:13:38 +00:00
rstubryan eca8bd7026 refactor(FE): Display raw age instead of using formatNumber 2026-01-22 11:36:02 +07:00
rstubryan 1e421e4230 refactor(FE): Show age as days and weeks in table 2026-01-22 11:34:29 +07:00
rstubryan 9cffa53122 feat(FE): Show age with week in sales report table 2026-01-22 11:26:47 +07:00
Rivaldi A N S 2ff217efcb Merge branch 'fix/dashboard' into 'development'
[FIX/FE] Fixing Select Input Component UI

See merge request mbugroup/lti-web-client!231
2026-01-21 09:21:28 +00:00
Rivaldi A N S 475467cca6 Merge branch 'fix/debt-supplier' into 'development'
[FIX/FE] Fixing Debt Supplier Export Excel and Select Input

See merge request mbugroup/lti-web-client!230
2026-01-21 09:19:54 +00:00
randy-ar d0f6e965f0 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/dashboard 2026-01-21 15:19:27 +07:00
randy-ar 9a1be88bce fix(FE): fixing dashboard select input UI component 2026-01-21 15:18:37 +07:00
Rivaldi A N S 76fb5a2625 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!227
2026-01-21 07:44:46 +00:00
Rivaldi A N S b75ed86949 Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Adjustment Recording Logic

See merge request mbugroup/lti-web-client!226
2026-01-21 07:42:39 +00:00
ValdiANS 426b6bfc85 chore: update chart and chart text color 2026-01-21 14:38:21 +07:00
randy-ar c7ffae68d8 fix(FE): adding color to negative value excel and change select UI 2026-01-21 14:27:59 +07:00
ValdiANS 362ae16c7d chore: set empty string for phase_id filter if ALL phase is selected 2026-01-21 14:19:58 +07:00
ValdiANS 149b14e0f8 fix: add form validation for configuration form 2026-01-21 14:18:14 +07:00
rstubryan 4d7bbaf771 feat(FE): Show product UOM suffix in recording form 2026-01-21 14:15:09 +07:00
rstubryan 99fc3f8cae refactor(FE): Standardize unit labels and number formatting 2026-01-21 13:45:12 +07:00
rstubryan 507f53ace8 refactor(FE): Use projectFlockKandangId for next-day recording 2026-01-21 13:29:35 +07:00
rstubryan fd4b584ccd refactor(FE): Use kilograms for egg weight fields 2026-01-21 13:23:31 +07:00
Rivaldi A N S f73672f65c Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!225
2026-01-21 06:13:23 +00:00
ValdiANS b62424af18 chore: rename updated_at column title to 'Diperbarui' 2026-01-21 13:08:05 +07:00
Rivaldi A N S 951d2bca0a Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!224
2026-01-21 05:40:07 +00:00
ValdiANS 4081a326e3 feat: add edit button 2026-01-21 12:38:30 +07:00
ValdiANS 0dbcb83c54 feat: set default value for date, kandang ID, and category from url query 2026-01-21 12:37:53 +07:00
ValdiANS 248d4f75d8 chore: rename daily checklist submenu to 'Formulir' 2026-01-21 12:36:40 +07:00
ValdiANS 4b2e00d91a chore: reorder activities in the correct order 2026-01-21 11:16:50 +07:00
ValdiANS d679b5b54e fix: show 'Umum' activity and fix select all phase bug 2026-01-21 11:15:57 +07:00
Rivaldi A N S b2fa4786b2 Merge branch 'dev/hotfix/restu' into 'development'
[FEAT/FE] Install ExcelJS and Refactor XLSX Export (Negative Color)

See merge request mbugroup/lti-web-client!223
2026-01-21 04:05:33 +00:00
rstubryan aaca46356a Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-21 10:55:00 +07:00
rstubryan 1f03222e42 feat(FE): Color negative accounts receivable red in XLSX export 2026-01-21 10:54:41 +07:00
rstubryan c472375f38 feat(FE): Use ExcelJS for customer payment export 2026-01-21 10:52:58 +07:00
Rivaldi A N S 469542bd2e Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!222
2026-01-21 03:45:17 +00:00
ValdiANS 98e1623c19 Merge branch 'development' into fix/daily-checklist 2026-01-21 10:43:08 +07:00
ValdiANS 480e8a3226 Merge branch 'development' into fix/daily-checklist 2026-01-21 10:42:37 +07:00
ValdiANS 7317eb7129 chore: remove time_type default value and check activity form time type validity 2026-01-21 10:41:27 +07:00
ValdiANS c4f8051fba chore: set selected phase to null if category change 2026-01-21 10:19:46 +07:00
Rivaldi A N S 7be811f2b1 Merge branch 'dev/hotfix/restu' into 'development'
[FE/FIX] Adjustment Detail Closing (Tab Penjualan) Flock and Kandang

See merge request mbugroup/lti-web-client!221
2026-01-21 03:07:55 +00:00
rstubryan 5053ce35df refactor(FE): Add sales/actual price fields and summary 2026-01-21 10:01:08 +07:00
Rivaldi A N S 9bd4df3f4c Merge branch 'fix/daily-marketing-report' into 'development'
[FIX/FE] Daily Marketing Report

See merge request mbugroup/lti-web-client!220
2026-01-21 02:45:11 +00:00
ValdiANS 6c4672e38e chore: add flock name to ClosingsTable 2026-01-21 09:43:13 +07:00
ValdiANS c695afa1e7 chore: rename total_sales_price to average_sales_price in SalesSummary 2026-01-21 09:40:22 +07:00
ValdiANS 93513c4a3a chore: add flock name in ClosingsTable 2026-01-21 09:36:36 +07:00
ValdiANS ead338fa0f chore: rename total_average_weight to average_weight_kg in SalesSummary 2026-01-21 09:31:21 +07:00
Rivaldi A N S afbb007309 Merge branch 'fix/transfer-to-laying' into 'development'
[FIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!219
2026-01-20 11:03:38 +00:00
ValdiANS 732fe85cde Merge branch 'development' into fix/transfer-to-laying 2026-01-20 17:37:06 +07:00
ValdiANS 43fe8ad1b3 feat: create PopoverContent component 2026-01-20 17:33:38 +07:00
ValdiANS d53afb6b74 feat: create PopoverButton component 2026-01-20 17:11:18 +07:00
ValdiANS e13f3358f4 chore: set maxQuantity min to 0 2026-01-20 17:09:57 +07:00
ValdiANS 26d89c35a5 chore: adjust TransferToLayingsTable styling 2026-01-20 17:07:24 +07:00
ValdiANS 7a45926c49 feat: adjust SalesSummary type 2026-01-20 17:04:41 +07:00
ValdiANS 0439c21ec6 chore: change button variant 2026-01-20 16:44:42 +07:00
ValdiANS 696ec3a69c feat: add total average weight and total sales price in table footer 2026-01-20 16:35:12 +07:00
ValdiANS 6b2f95b9a3 chore: adjust SidebarMenu styling 2026-01-20 16:33:35 +07:00
ValdiANS 324b6b69e2 chore: use TABLE_DEFAULT_STYLING for default styling 2026-01-20 16:31:05 +07:00
ValdiANS 876217d1af chore: adjust MainDrawer styling 2026-01-20 16:30:04 +07:00
ValdiANS 7a2bdb25e4 chore: change className.drawerContent to className.drawerSidebarContent 2026-01-20 16:29:03 +07:00
ValdiANS 1a3ea5be8c chore: add roboto font 2026-01-20 16:15:38 +07:00
ValdiANS 572e5233b4 chore: add roboto font 2026-01-20 16:13:29 +07:00
ValdiANS 6ece591d2b chore: update daisyUI version 2026-01-20 16:11:45 +07:00
Rivaldi A N S 122bdbbf54 Merge branch 'fix/finance' into 'development'
[FIX/FE] Fixing Form State in Module Finance

See merge request mbugroup/lti-web-client!218
2026-01-20 08:52:47 +00:00
Rivaldi A N S 5eb1296391 Merge branch 'fix/marketing' into 'development'
[FIX/FE] Fixing Edit Product Sales Order per Row

See merge request mbugroup/lti-web-client!217
2026-01-20 08:51:42 +00:00
Rivaldi A N S 3cc85a894f Merge branch 'fix/master-data' into 'development'
[FIX/FE] Fixing Unit in Production Standard

See merge request mbugroup/lti-web-client!216
2026-01-20 08:50:16 +00:00
randy-ar 71e6ac9c63 fix(FE): fixing url encode for filters 2026-01-20 15:34:33 +07:00
randy-ar 596e2d0095 fix(FE): change nominal to absolute value, change form state initial balance, and changes filter options 2026-01-20 15:22:35 +07:00
randy-ar 4cb8343f74 fix(FE): fixing edit product SO per row 2026-01-20 15:00:50 +07:00
randy-ar fc785bc63c fix(FE): fixing unit production standard 2026-01-20 14:39:44 +07:00
Rivaldi A N S defbcd9867 Merge branch 'fix/marketing' into 'development'
[FIX/FE] Adding Button Edit and Delete per Product on Marketing Form

See merge request mbugroup/lti-web-client!213
2026-01-20 04:36:51 +00:00
randy-ar f69fc08ef8 feat(FE): adding delete per product row delivery order 2026-01-20 11:06:53 +07:00
randy-ar 41bf12846d Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/marketing 2026-01-20 10:44:22 +07:00
randy-ar bbb9c5f190 feat(FE): adding edit and delete per product row sales order 2026-01-20 10:43:13 +07:00
Rivaldi A N S de11534e20 Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Adjustment Recording and Purchase Order

See merge request mbugroup/lti-web-client!212
2026-01-20 03:35:40 +00:00
rstubryan 8b0a6f054b refactor(FE): Format zero transport per item as currency 2026-01-20 10:31:32 +07:00
rstubryan 46b819c200 refactor(FE): Use approval step_name or action for status 2026-01-20 10:27:16 +07:00
rstubryan 72356917ff refactor(FE): Show dash for missing transport per item 2026-01-20 10:23:40 +07:00
rstubryan 28a32cb6c4 refactor(FE): Move travel document button into receipt section 2026-01-20 10:21:22 +07:00
rstubryan 0e179f1643 refactor(FE): Use location type guard for warehouse access 2026-01-20 10:08:37 +07:00
rstubryan 6319b6d5fe refactor(FE): Guard warehouse before accessing location 2026-01-20 10:07:39 +07:00
Rivaldi A N S a538c3ea90 Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Adjustment Recording, Biaya and HPP Harian Kandang (Report)

See merge request mbugroup/lti-web-client!211
2026-01-20 02:09:43 +00:00
rstubryan cbc54eb501 refactor(FE): Use name_with_periode in HppPerKandang cell 2026-01-19 20:57:16 +07:00
rstubryan 67f7a68f1b refactor(FE): Use ProjectFlockKandangApi for kandang select 2026-01-19 20:52:37 +07:00
rstubryan b1981867ff refactor(FE): Make depletion and egg fields optional 2026-01-19 17:52:19 +07:00
rstubryan 8a84542c60 refactor(FE): Rename expense item note to notes 2026-01-19 17:27:49 +07:00
rstubryan f091b4be43 refactor(FE): Show kandangs table for add-request and selection 2026-01-19 17:26:41 +07:00
rstubryan 6e4c214821 refactor(FE): Remove Warehouse column from RecordingTable 2026-01-19 17:21:48 +07:00
Rivaldi A N S aa6d205491 Merge branch 'dev/hotfix/restu' into 'development'
[FIX/FE] Data Refactor and UI Adjustment (Closing (Penjualan), Transfer Stock, Laporan (Kontrol Pembayaran Customer dan HPP Harian Kandang), Biaya, Recording)

See merge request mbugroup/lti-web-client!210
2026-01-19 09:15:25 +00:00
rstubryan 2e6e11984e Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-19 16:04:40 +07:00
Rivaldi A N S 0824225080 Merge branch 'fix/dateinput' into 'development'
[FIX/FE] Fixing Dateinput Component

See merge request mbugroup/lti-web-client!209
2026-01-19 09:04:19 +00:00
rstubryan e4ae1566f1 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-19 16:00:39 +07:00
rstubryan 0f21731008 feat(FE): Add Lokasi and Kandang columns in RecordingTable 2026-01-19 16:00:27 +07:00
randy-ar 9b2b5d8307 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/dateinput 2026-01-19 15:50:06 +07:00
randy-ar ff6f6136cc fix(FE): fixing component date input, remove alert native function & remove console.error 2026-01-19 15:49:31 +07:00
Rivaldi A N S 4ad2b54128 Merge branch 'fix/recording' into 'development'
[FIX/FE] Fixing Recording Form

See merge request mbugroup/lti-web-client!208
2026-01-19 08:38:25 +00:00
randy-ar 7b939f57af Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/recording 2026-01-19 15:28:48 +07:00
randy-ar 18f3295562 fix(FE): fixing initial state in form recording & refactor formik message errors list 2026-01-19 15:28:16 +07:00
rstubryan 9c540e7cd8 refactor(FE): Add guards for latest_approval and move search 2026-01-19 15:27:49 +07:00
rstubryan eefec93811 refactor(FE): Add Kandang and Location to Recording type 2026-01-19 14:59:26 +07:00
Rivaldi A N S 04e54044e9 Merge branch 'fix/sapronak-closing-report' into 'development'
[FIX/FE] Sapronak Closing Report

See merge request mbugroup/lti-web-client!207
2026-01-19 07:49:05 +00:00
ValdiANS e143668f82 feat: create getAllIncomingSapronakSummaryFetcher and getAllOutgoingSapronakSummaryFetcher method 2026-01-19 14:46:29 +07:00
ValdiANS 949b5cbc12 feat: create Closing Sapronak Summary type 2026-01-19 14:44:35 +07:00
ValdiANS 0f64baca23 feat: show Closing Sapronak Summary table 2026-01-19 14:42:04 +07:00
ValdiANS d9c154997d feat: create Closing Sapronak Summary component 2026-01-19 14:41:20 +07:00
rstubryan b3f8fc451d feat(FE): Add Rekapitulasi sheet to Excel export 2026-01-19 14:37:23 +07:00
rstubryan da040a4f7e refactor(FE): Format style objects in HppPerkandangExport 2026-01-19 14:36:21 +07:00
rstubryan 200290a0b3 refactor(FE): Remove header cell bottom and right borders in PDF export 2026-01-19 14:27:27 +07:00
rstubryan 366864582f feat(FE): Add TOTAL row to HppPerkandang PDF export 2026-01-19 14:14:04 +07:00
ValdiANS 876d564f26 fix: refactor confirmationModalDeleteClickHandler function 2026-01-19 14:06:04 +07:00
ValdiANS 979f803d75 chore: rename title 2026-01-19 14:05:08 +07:00
rstubryan f4166f4dbd refactor(FE): Keep select menus open and show selected options 2026-01-19 13:41:15 +07:00
rstubryan 56d4eca034 refactor(FE): Validate weight max is not less than min 2026-01-19 13:13:23 +07:00
rstubryan 24ff7a080f refactor(FE): Reset products when source warehouse changes 2026-01-19 13:03:07 +07:00
rstubryan ee1f759a37 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-19 10:54:46 +07:00
rstubryan 23c758b0cf refactor(FE): Prevent selecting same source and destination 2026-01-19 10:47:12 +07:00
rstubryan 1c002a1b95 refactor(FE): Stop resetting deliveries on product change 2026-01-19 10:44:18 +07:00
rstubryan fb980c38c9 refactor(FE): Toast warehouse duplicate error on validation 2026-01-19 10:42:20 +07:00
rstubryan dc2c2228a8 refactor(FE): Extract and memoize form event handlers 2026-01-19 10:28:08 +07:00
rstubryan 6def4e0fcd refactor(FE): Extract DeliveryDocumentSchema for reuse 2026-01-19 10:16:46 +07:00
rstubryan aaaa126c42 refactor(FE): Display zero and negative aging days 2026-01-19 09:32:59 +07:00
rstubryan 70a15d3044 refactor(FE): Rename customer_id to customer_ids in API and UI 2026-01-19 09:19:47 +07:00
Rivaldi A N S 5c8e97ebf9 Merge branch 'fix/dashboard' into 'development'
[FIX/FE] Fixing Export PDF, Data Types and Filter State

See merge request mbugroup/lti-web-client!206
2026-01-19 02:12:21 +00:00
rstubryan f8ae023c45 refactor(FE): Only render kandang table when selected 2026-01-19 00:17:11 +07:00
rstubryan d03414f7ab refactor(FE): Refactor expense form handlers and schema 2026-01-19 00:07:00 +07:00
rstubryan 3cda11c66e refactor(FE): Filter warehouse options by EKSPEDISI flag 2026-01-18 23:43:31 +07:00
randy-ar 55b50d4184 feat(FE): add state filter dashboard with store 2026-01-18 19:30:54 +07:00
randy-ar a9c22d778b refactor(FE): export charts with hidden html renderer and adding package html-to-image 2026-01-18 18:57:10 +07:00
randy-ar c576933ba2 fix(FE): update dashboard line chart and types 2026-01-18 16:15:18 +07:00
rstubryan b66054c9a2 refactor(FE): Use nonstock_id and make select clearable 2026-01-17 21:43:49 +07:00
rstubryan ccf535cbd9 refactor(FE): Improve expense form validation messages 2026-01-17 21:30:03 +07:00
rstubryan df550abc46 refactor(FE): Simplify recording approval line steps 2026-01-17 21:04:29 +07:00
rstubryan 0aa96b9c46 refactor(FE): Remove max-width constraints, use two-column grid 2026-01-17 20:53:39 +07:00
rstubryan 4391fe1de7 refactor(FE): Capitalize payment status text 2026-01-17 20:37:43 +07:00
rstubryan 6377557ef0 refactor(FE): Reset products and deliveries on warehouse change 2026-01-17 20:33:13 +07:00
rstubryan 4d319ca9c8 refactor(FE): Comment out age column in SalesReportTable 2026-01-17 20:25:23 +07:00
rstubryan 93c886551d Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2026-01-17 20:19:51 +07:00
Rivaldi A N S d31c1deaa2 Merge branch 'hotfix/finance' into 'development'
[HOTFIX/FE] FIxing Test Scenario in Finance Module

See merge request mbugroup/lti-web-client!205
2026-01-17 09:55:02 +00:00
Rivaldi A N S aad08593c7 Merge branch 'hot-fix/marketing-disabled-edit-customer' into 'development'
[HOTFIX/FE] Disabled Select input customer

See merge request mbugroup/lti-web-client!204
2026-01-17 09:54:41 +00:00
randy-ar 36da05890a hotfix(FE): fixing failed test scenario in module finance 2026-01-17 16:45:56 +07:00
rstubryan a82c5e5593 refactor(FE): Rename price to unit_price in customer payments 2026-01-17 16:16:34 +07:00
randy-ar 08715e39c2 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into hot-fix/marketing-disabled-edit-customer 2026-01-17 15:59:49 +07:00
randy-ar 138ad6a7c9 hotfix(FE): change select input component to disabled 2026-01-17 15:59:08 +07:00
Rivaldi A N S 9ae5bdd969 Merge branch 'dev/hotfix/restu' into 'development'
[HOTFIX/FE] Fix Recording Record Date and Purchase Order Staff Edit Mode

See merge request mbugroup/lti-web-client!202
2026-01-17 06:12:01 +00:00
rstubryan d19b1e885e refactor(FE): Compute item total on qty change and limit reset 2026-01-17 12:59:41 +07:00
rstubryan f4abfd4279 refactor(FE): Dismiss toast notifications on unmount 2026-01-17 12:54:16 +07:00
rstubryan 1152b6d2c3 refactor(FE): Disable Submit button when duplicate error shown 2026-01-17 12:45:11 +07:00
rstubryan 0cdbff6954 refactor(FE): Validate recording date and handle null location 2026-01-17 12:32:55 +07:00
rstubryan f32b77c552 refactor(FE): Add location, project_flock and kandang fields 2026-01-17 12:24:32 +07:00
rstubryan cd9fa31ad7 refactor(FE): Disable form fields in edit mode 2026-01-17 12:11:30 +07:00
rstubryan 4c4c70e10f refactor(FE): Validate duplicates by selected record date 2026-01-17 12:03:32 +07:00
Rivaldi A N S 67d695303e Merge branch 'hot-fix/project-flock-status' into 'development'
[FIX/FE] Fix Approval Status Badge

See merge request mbugroup/lti-web-client!200
2026-01-17 04:15:40 +00:00
Rivaldi A N S b85bace073 Merge branch 'hot-fix/master-data-flock' into 'development'
[FIX/FE] Show server error message

See merge request mbugroup/lti-web-client!199
2026-01-17 04:15:14 +00:00
randy-ar 835ba077d8 fix(FE): change status badge kandang aktif 2026-01-17 11:08:25 +07:00
randy-ar 13abc6d7ce fix(FE): change status badge 2026-01-17 11:02:22 +07:00
randy-ar a26919f037 fix(FE): show server error 2026-01-17 10:49:41 +07:00
Rivaldi A N S e6cbb3013d Merge branch 'hot-fix/project-flock-kandang-options' into 'development'
[FIX/FE] Fix Limit Data Kandang in Project Form

See merge request mbugroup/lti-web-client!197
2026-01-17 02:53:03 +00:00
randy-ar c55081f358 fix(FE): fix limit fetch data kandang 2026-01-17 09:48:37 +07:00
Rivaldi A N S ff9ad8237c Merge branch 'hot-fix/closing-finance-kandang' into 'development'
[FIX/FE] Hotfix Closing Finance Kandang

See merge request mbugroup/lti-web-client!195
2026-01-16 15:02:10 +00:00
randy-ar f371d06386 fix(FE): add depedency to useMemo rows data 2026-01-16 21:55:39 +07:00
randy-ar 79cb89b9a0 fix(FE): delete console log debug 2026-01-16 21:11:46 +07:00
randy-ar 9a3617edf1 fix(FE): hot fix closing finance per kandang 2026-01-16 21:04:23 +07:00
Rivaldi A N S 80dfbcb858 Merge branch 'feat/closing-overhead-kandang' into 'development'
[FIX/FE] Fixing Slicing UI Closing Overhead Kandang

See merge request mbugroup/lti-web-client!194
2026-01-16 12:01:48 +00:00
Rivaldi A N S db51619fbe Merge branch 'feat/closing-finance-kandang' into 'development'
[FEAT/FE] Refactor Response Data Closing Finance

See merge request mbugroup/lti-web-client!193
2026-01-16 12:01:29 +00:00
Rivaldi A N S 6395a32f43 Merge branch 'fix/data-refactor-and-ui-adjustment' into 'development'
[FIX/FE] Data Refactor and UI Adjustment (Recording, Purchase, Finance, Marketing & Sales Report)

See merge request mbugroup/lti-web-client!192
2026-01-16 12:00:37 +00:00
Rivaldi A N S 3c6f7ce0d3 Merge branch 'fix/finance-select-input' into 'development'
[FIX/FE] Implement Lazy Loading Select Input

See merge request mbugroup/lti-web-client!191
2026-01-16 12:00:09 +00:00
randy-ar 01b8841e3c fix(FE): adjust ui closing overhead kandang 2026-01-15 21:14:55 +07:00
randy-ar 40e8f52fe4 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into feat/closing-overhead-kandang 2026-01-15 20:16:11 +07:00
randy-ar 8c6a87c011 feat(FE): adding closing finance per kandang 2026-01-15 20:08:21 +07:00
rstubryan a17089f4bb refactor(FE): Relax expedition vendor validation 2026-01-15 19:37:55 +07:00
rstubryan 45700be730 refactor(FE): Improve vehicle number validation message and set
expedition flag on supplier
2026-01-15 19:32:09 +07:00
rstubryan 228e79bb31 refactor(FE): Make vehicle/expedition/transport fields optional 2026-01-15 19:29:03 +07:00
rstubryan 87bf474cf6 refactor(FE): Add customer address as card subtitle 2026-01-15 18:01:15 +07:00
rstubryan b25418b51e Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-15 17:54:56 +07:00
rstubryan 0a24c4541f refactor(FE): Make feed and doc supplier arrays nullable 2026-01-15 17:53:18 +07:00
rstubryan 7d3a4c1ecc refactor(FE): Use memoized IDs and badges for standards 2026-01-15 17:47:03 +07:00
rstubryan 8fe51c976b refactor(FE): Show additional flock info in recording form 2026-01-15 17:29:09 +07:00
randy-ar 5a4e3ab5ab Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into feat/closing-finance-kandang 2026-01-15 17:23:57 +07:00
randy-ar a1bbe4e2d7 fix(FE): implement lazy loading select button on marketing index 2026-01-15 17:03:25 +07:00
Rivaldi A N S 6c8e901a99 Merge branch 'fix/master-data-delete-error-message' into 'development'
[FIX/FE] Master Data Delete Error Message

See merge request mbugroup/lti-web-client!190
2026-01-15 09:57:28 +00:00
ValdiANS a2e04dad9f chore: implement delete error message toast 2026-01-15 16:55:16 +07:00
randy-ar 73100aa1ce fix(FE): implement lazy loading select button on project flock index 2026-01-15 16:53:39 +07:00
randy-ar e349b9dfa4 fix(FE): implement lazy loading select button on finance module 2026-01-15 16:48:11 +07:00
rstubryan 4a9cbdc219 refactor(FE): Use initialValues total_chick_qty for edit/detail 2026-01-15 16:41:24 +07:00
rstubryan 76c1b2f628 refactor(FE): Derive current total chick qty from flock data 2026-01-15 16:35:32 +07:00
rstubryan 0a5414a3ac refactor(FE): Add bottom margin to production standards text 2026-01-15 16:28:32 +07:00
rstubryan c7b4361cb6 feat(FE): Add Production Standard modal and table 2026-01-15 16:24:45 +07:00
rstubryan 00e0bc387b Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-15 16:17:22 +07:00
rstubryan 4285e2e269 refactor(FE): Enable backdrop close and set FCR table pageSize 2026-01-15 16:16:32 +07:00
Rivaldi A N S 9df64eeafa Merge branch 'fix/master-data-nonstock' into 'development'
[FIX/FE] Master Data Nonstock

See merge request mbugroup/lti-web-client!188
2026-01-15 09:15:10 +00:00
Rivaldi A N S 34901aa11c Merge branch 'fix/master-data-production-standard' into 'development'
[FIX/FE] Fixing Error Message and Fixing UI

See merge request mbugroup/lti-web-client!186
2026-01-15 09:14:26 +00:00
ValdiANS b2ce9c93b7 chore: remove unnecessary data 2026-01-15 16:13:54 +07:00
Rivaldi A N S 0c4448f396 Merge branch 'fix/master-data-nonstock' into 'development'
[FIX/FE] Master Data Nonstock

See merge request mbugroup/lti-web-client!187
2026-01-15 09:12:26 +00:00
ValdiANS 7114470c13 chore: access correct UOM id 2026-01-15 16:11:12 +07:00
rstubryan 0de4f9d745 feat(FE): Display FCR standards table in RecordingForm 2026-01-15 16:07:17 +07:00
randy-ar a6fe07de07 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/master-data-production-standard 2026-01-15 16:06:13 +07:00
randy-ar 438082c94c fix(FE): fixing error message on submit and fixing ui 2026-01-15 16:05:05 +07:00
Rivaldi A N S 081048f0c5 Merge branch 'fix/closing-report-per-kandang' into 'development'
[FIX/FE] Closing Report Per Kandang

See merge request mbugroup/lti-web-client!185
2026-01-15 09:02:18 +00:00
ValdiANS fce2cfee73 feat: implement closing production data per kandang 2026-01-15 15:56:47 +07:00
ValdiANS bd64694c73 feat: implement closing sapronak per kandang 2026-01-15 15:56:30 +07:00
rstubryan 105d23e4f7 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-15 15:50:28 +07:00
rstubryan 68f9e27b5f refactor(FE): Reorder fields in RecordingForm 2026-01-15 15:48:18 +07:00
rstubryan d786b7b5ba feat(FE): Add warehouse display and standard detail modals 2026-01-15 15:41:11 +07:00
rstubryan 23e8487a97 refactor(FE): Show zero values in RecordingForm 2026-01-15 15:21:16 +07:00
Rivaldi A N S 02b97117eb Merge branch 'feat/FE/US-81/production-result-report' into 'development'
[FEAT/FE][US#81] Production Result Report PDF

See merge request mbugroup/lti-web-client!184
2026-01-15 08:15:49 +00:00
Rivaldi A N S 0f6e224870 Merge branch 'fix/project-flock-select-options' into 'development'
[FIX/FE] Implementasi Select Lazy Loading Form Project Flock

See merge request mbugroup/lti-web-client!183
2026-01-15 08:14:58 +00:00
ValdiANS e15b7e11d3 feat: create ProductionResultReportPDF component 2026-01-15 15:14:33 +07:00
ValdiANS 8f55ced55a feat: add export to pdf functionality 2026-01-15 15:14:17 +07:00
ValdiANS 781a5ca0d9 chore: use real permission for daily checklist menu 2026-01-15 15:14:04 +07:00
rstubryan ac84841b05 feat(FE): Add production metric columns and table styling 2026-01-15 15:06:41 +07:00
rstubryan a08ab7abaf feat(FE): Add Periode and Gudang columns and show week 2026-01-15 14:47:49 +07:00
randy-ar 1d689da546 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/project-flock-select-options 2026-01-15 14:45:40 +07:00
Rivaldi A N S 96f96f6c5a Merge branch 'fix/marketing' into 'development'
[FIX/FE] Fixing Module Marketing Change UI Layout

See merge request mbugroup/lti-web-client!178
2026-01-15 07:43:31 +00:00
Rivaldi A N S a403800fb0 Merge branch 'fix/debt-supplier' into 'development'
[FIX/FE] Adjust UI Debt Supplier

See merge request mbugroup/lti-web-client!177
2026-01-15 07:43:03 +00:00
randy-ar 49b7ca4be5 fix(FE): resolve conflict 2026-01-15 14:39:06 +07:00
rstubryan dd080b1d19 refactor(FE): Add record_date and DateInput to recording form 2026-01-15 14:08:09 +07:00
rstubryan 63461173e5 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-15 13:52:45 +07:00
rstubryan 9d7140beb6 refactor(FE): Use egg production as remaining columns 2026-01-15 13:52:21 +07:00
rstubryan 817420ee62 refactor(FE): Rename Ayam labels to Telur in HPP reports 2026-01-15 13:35:00 +07:00
rstubryan f3b1091890 refactor(FE): Add infinite scroll to flock selects 2026-01-15 12:12:03 +07:00
rstubryan c9bace04ec refactor(FE): Use absolute import for Badge 2026-01-15 12:07:55 +07:00
rstubryan dc3b4f1850 refactor(FE): Use useSelect for ProjectFlock with pagination 2026-01-15 12:05:34 +07:00
Rivaldi A N S 27a398a1c8 Merge branch 'fix/master-data-product' into 'development'
[FIX/FE] Master Data Product

See merge request mbugroup/lti-web-client!181
2026-01-15 05:03:53 +00:00
Rivaldi A N S 9a87f1c404 Merge branch 'feat/closing-finance-kandang' into 'development'
[FEAT/FE] Closing Finance Kandang

See merge request mbugroup/lti-web-client!176
2026-01-15 05:00:43 +00:00
Rivaldi A N S fc1a0d6a3f Merge branch 'fix/dashboard' into 'development'
[FIX/FE] Adding Export Button and Fixing Filter

See merge request mbugroup/lti-web-client!175
2026-01-15 05:00:07 +00:00
ValdiANS 6a7990e722 fix: make suppliers optional 2026-01-15 11:59:25 +07:00
rstubryan 294c843bd4 refactor(FE): Use useSelect for project flock filter 2026-01-15 11:58:38 +07:00
rstubryan 12f22833c9 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-15 11:48:17 +07:00
Rivaldi A N S d46355f7f0 Merge branch 'fix/master-data-product' into 'development'
[FIX/FE] Master Data Product

See merge request mbugroup/lti-web-client!180
2026-01-15 04:47:21 +00:00
ValdiANS bf38178969 chore: update product type 2026-01-15 11:46:19 +07:00
ValdiANS 790b590668 feat: use real permission for daily checklist 2026-01-15 11:46:10 +07:00
ValdiANS 3952704643 chore: fix typo in placeholder 2026-01-15 11:45:51 +07:00
ValdiANS 57a148b6cf feat: add price per supplier input in product form 2026-01-15 11:45:31 +07:00
randy-ar 8a7245f5dd Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into feat/closing-finance-kandang 2026-01-15 11:14:09 +07:00
rstubryan b02a3c5eee refactor(FE): Add onMenuScrollToBottom to product selects 2026-01-15 10:51:33 +07:00
rstubryan a1301121ac refactor(FE): Refactor selects to use useSelect hook 2026-01-15 10:44:27 +07:00
rstubryan 3bc5030a3d refactor(FE): Use useSelect for warehouse options 2026-01-15 10:17:08 +07:00
rstubryan 4b88b684af refactor(FE): Load locations by area and disable location select 2026-01-15 10:12:52 +07:00
rstubryan cf332b5346 refactor(FE): Add load-on-scroll for expedition vendor select 2026-01-15 10:00:56 +07:00
Rivaldi A N S 94c6d82967 Merge branch 'feat/select-input-lazy-loading' into 'development'
[FEAT/FE] SelectInput Lazy Loading Implementation

See merge request mbugroup/lti-web-client!179
2026-01-15 02:56:09 +00:00
ValdiANS c75563491f feat: implement lazy loading in SelectInput 2026-01-15 09:48:02 +07:00
ValdiANS 3827204f13 chore: make basePath nullable 2026-01-15 09:47:11 +07:00
randy-ar 76e15d13ad fix(FE): adding filter information and supplier category on export pdf 2026-01-15 00:52:29 +07:00
randy-ar 5e7f55000a fix(FE): add sales person, refactor calculate total weight and price, add uom information and implement lazy load select 2026-01-15 00:32:55 +07:00
rstubryan 427c8aec34 feat(FE): Join array fields into comma-separated strings 2026-01-14 22:28:45 +07:00
rstubryan f1dba4012a refactor(FE): Update customer payment types and exports 2026-01-14 22:17:24 +07:00
rstubryan a72fbec5ce refactor(FE): Remove CN and PPN columns and bold receivables 2026-01-14 22:02:48 +07:00
rstubryan d8e134d404 feat(FE): Add customer payment permission to finance route 2026-01-14 21:59:03 +07:00
rstubryan 359326e575 refactor(FE): Rename 'Populasi Awal' column to 'Populasi Ayam' 2026-01-14 21:45:52 +07:00
rstubryan dbe9b26818 feat(FE): Adjust recording form grid and add chick count 2026-01-14 21:42:36 +07:00
rstubryan b290f7692a refactor(FE): Show current chick total in recording form 2026-01-14 21:39:38 +07:00
randy-ar 7a6bee57c2 fix(FE): fix report excel debt supplier change debt_price to balance 2026-01-14 19:20:16 +07:00
randy-ar b52a414eb0 fix(FE): adjust slicing ui debt supplier 2026-01-14 19:11:38 +07:00
rstubryan 4137683d05 refactor(FE): Hide 'hari' for zero or negative aging days 2026-01-14 17:55:38 +07:00
randy-ar 9237d4e731 fix(FE): implement lazy load select project flock 2026-01-14 17:10:42 +07:00
rstubryan efde742518 refactor(FE): Highlight negative accounts receivable values 2026-01-14 17:03:40 +07:00
rstubryan 916de1432b refactor(FE): Adjust column span in CustomerPaymentTab 2026-01-14 16:59:45 +07:00
rstubryan e134f0994b refactor(FE): Remove credit_note and ppn fields 2026-01-14 16:57:52 +07:00
randy-ar f22ba83dd0 feat(FE): hit api to endpoint closing finance kandang 2026-01-14 16:44:50 +07:00
randy-ar 3cb11f6158 fix(FE): add empty state overlay on chart null value 2026-01-14 16:27:13 +07:00
rstubryan c5baff6f33 refactor(FE): Update column headers in CustomerPaymentTab 2026-01-14 15:55:20 +07:00
rstubryan ab2175d903 refactor(FE): Simplify table cells and render initial balance row 2026-01-14 15:46:03 +07:00
randy-ar 524036a6bf fix(FE): implement lazy load options select 2026-01-14 15:43:53 +07:00
randy-ar 6c320ce59a Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/dashboard 2026-01-14 15:37:44 +07:00
randy-ar cb8a1a17ac feat(FE): adding export feature on dashboard 2026-01-14 15:36:21 +07:00
rstubryan 08c28f4077 refactor(FE): Rename payment fields and add initial balance row 2026-01-14 15:14:33 +07:00
rstubryan 66fa65e4bb refactor(FE): Disable sales and filter_by until backend ready 2026-01-14 14:29:40 +07:00
rstubryan 01e94b57c1 refactor(FE): Disable row selection for approved recordings 2026-01-14 14:14:48 +07:00
rstubryan 8d586e7cb4 refactor(FE): Switch FinanceApi to production and remove import 2026-01-14 13:53:45 +07:00
Rivaldi A N S 447a953ed3 Merge branch 'feat/customer-control-ui-adjustment' into 'development'
[FEAT/FE] Customer Control UI Adjustment (Kontrol Pembayaran Customer)

See merge request mbugroup/lti-web-client!174
2026-01-14 06:36:33 +00:00
rstubryan 141d695a7d feat(FE): Load more select options on scroll 2026-01-14 13:10:51 +07:00
rstubryan ae35d42484 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-14 12:00:50 +07:00
rstubryan 3f285a74bc refactor(FE): Append title classes to Card and style customer card 2026-01-14 11:56:09 +07:00
Rivaldi A N S c81f7faf93 Merge branch 'feat/select-input-lazy-loading' into 'development'
[FEAT/FE] Implement SelectInput lazy loading

See merge request mbugroup/lti-web-client!173
2026-01-14 04:53:27 +00:00
Rivaldi A N S c81b250cbb Merge branch 'fix/debt-supplier' into 'development'
[FIX/FE] Fixing Debt Supplier Report Ledger

See merge request mbugroup/lti-web-client!172
2026-01-14 04:51:20 +00:00
randy-ar fc71defa08 feat(FE): adding button filter component 2026-01-14 11:43:10 +07:00
ValdiANS 54e05b7150 feat: implement lazy loading in SelectInput component in master data form 2026-01-14 11:36:57 +07:00
rstubryan d28fa77405 feat(FE): Fetch sales from UserApi and use sales_id param 2026-01-14 11:35:10 +07:00
rstubryan 53277b5893 refactor(FE): Use customerOptions type for filterCustomer state 2026-01-14 11:16:33 +07:00
rstubryan 97d131be12 refactor(FE): Cleanup types and remove pagination 2026-01-14 11:06:29 +07:00
rstubryan 3937c27c77 feat(FE): Show active filter count on Filter button 2026-01-14 10:49:21 +07:00
randy-ar bdf84c3802 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/debt-supplier 2026-01-14 10:41:24 +07:00
randy-ar ba679865c4 fix(FE): fixing laporan ledger debt supplier 2026-01-14 10:40:54 +07:00
ValdiANS 8d7adbbd27 feat: implement lazy loading in SelectInput 2026-01-14 10:35:51 +07:00
rstubryan adb8d0f69e feat(FE): Add checkbox multi-select and components prop 2026-01-14 10:04:51 +07:00
Rivaldi A N S e6eac6b62d Merge branch 'fix/marketing-report' into 'development'
[FIX/FE] Daily Marketing Report

See merge request mbugroup/lti-web-client!171
2026-01-14 02:32:18 +00:00
Rivaldi A N S e87f9087e1 Merge branch 'fix/data-refactor-and-ui-adjustment' into 'development'
[FIX/FE] Data Refactor and UI Adjustment (Closing, Biaya, Finance Report, Sales Report, Recording)

See merge request mbugroup/lti-web-client!170
2026-01-14 02:30:03 +00:00
ValdiANS c4b505047c chore: add MARKETING_DATE_FILTER_TYPE_OPTIONS constant 2026-01-14 09:29:27 +07:00
ValdiANS fee9328699 chore: adjust table columns accessorKey for filter 2026-01-14 09:29:15 +07:00
ValdiANS b90fdabc4b feat: date filter type 2026-01-14 09:28:47 +07:00
ValdiANS a5d2d85572 chore: hide AWG std and AWG act 2026-01-14 09:28:16 +07:00
rstubryan 4560073f6a refactor(FE): Add record_date to recording payloads 2026-01-14 00:02:44 +07:00
rstubryan a205e57d39 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-13 16:49:12 +07:00
rstubryan 582b971c09 refactor(FE): Change endpoint path to reports 2026-01-13 16:47:58 +07:00
Rivaldi A N S 54ce9e5458 Merge branch 'feat/closing-overhead-kandang' into 'development'
[FEAT/FE] Closing Overhead Kandang

See merge request mbugroup/lti-web-client!169
2026-01-13 09:47:01 +00:00
rstubryan 8481b77c90 refactor(FE): Adjust cumulative depletion table layout 2026-01-13 16:21:41 +07:00
rstubryan e77a43300a refactor(FE): Nest project_flock in Recording and update UI 2026-01-13 16:01:30 +07:00
rstubryan 34d7310cc9 refactor(FE): Style accounts receivable in red and adjust badge 2026-01-13 15:32:39 +07:00
randy-ar 8998d815a5 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into feat/closing-overhead-kandang 2026-01-13 15:23:27 +07:00
randy-ar e5ec0f8deb feat(FE): closing report overhead per kandang 2026-01-13 15:20:36 +07:00
rstubryan 60eaec261d refactor(FE): Render payment notes as Badge and highlight balance 2026-01-13 15:20:27 +07:00
rstubryan 92bfef850a refactor(FE): Enhance customer payment PDF export and filters 2026-01-13 14:31:30 +07:00
rstubryan 502564da0a refactor(FE): Exclude step 6 and include step 4 in edit check 2026-01-13 13:43:34 +07:00
rstubryan 8b99be34ae Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2026-01-13 13:36:13 +07:00
rstubryan a781431683 refactor(FE): Rename Type column header to Jenis 2026-01-13 13:35:56 +07:00
Rivaldi A N S d34413fa3a Merge branch 'fix/debt-supplier' into 'development'
[FIX/FE] Delete Dummy Data and Separate Service API File

See merge request mbugroup/lti-web-client!168
2026-01-13 06:29:00 +00:00
randy-ar b9212d1241 fix(FE): delete dummy data and separate service api file 2026-01-13 12:03:45 +07:00
Rivaldi A N S 6a58be8c67 Merge branch 'fix/form-error-message' into 'development'
[FIX/FE] Form Error Message

See merge request mbugroup/lti-web-client!167
2026-01-13 04:35:43 +00:00
Rivaldi A N S d4b8d25bd5 Merge branch 'fix/debt-supplier-and-project-flock' into 'development'
[FIX/FE] Refactor data response debt supplier and fix Floating Action Button Project Flock

See merge request mbugroup/lti-web-client!166
2026-01-13 04:33:15 +00:00
Rivaldi A N S 19c4e0fd4b Merge branch 'dev/randy' into 'fix/debt-supplier-and-project-flock'
[FIX/FE] Refactor data response debt supplier and fix Floating Action Button Project Flock

See merge request mbugroup/lti-web-client!165
2026-01-13 04:29:21 +00:00
ValdiANS 9ffa60b935 fix: use AlertErrorList for showing error message list 2026-01-13 11:25:10 +07:00
Rivaldi A N S 55376e9631 Merge branch 'feat/closing-kandang-hpp-ekspedisi-dan-penjualan' into 'development'
[FEAT/FE] Add Closing (Kandang) HPP Expedition (Ekspedisi) dan Sales (Penjualan)

See merge request mbugroup/lti-web-client!164
2026-01-13 04:15:46 +00:00
rstubryan 06accca19e feat(FE): Support per-kandang sales fetching 2026-01-13 11:00:36 +07:00
rstubryan 0f5ac917d2 feat(FE): Support fetching HPP ekspedisi by kandang 2026-01-13 10:57:40 +07:00
238 changed files with 18378 additions and 8846 deletions
+914 -10
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -19,7 +19,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"exceljs": "^4.4.0",
"formik": "^2.4.6", "formik": "^2.4.6",
"html-to-image": "^1.11.13",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^3.0.4", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
@@ -54,7 +56,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"daisyui": "^5.5.8", "daisyui": "^5.5.14",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "^15.5.7", "eslint-config-next": "^15.5.7",
"husky": "^9.1.7", "husky": "^9.1.7",
+21 -4
View File
@@ -35,13 +35,30 @@ const ClosingDetailPage = () => {
); );
const { data: salesData, isLoading: isLoadingSales } = useSWR( const { data: salesData, isLoading: isLoadingSales } = useSWR(
closingId ? `sales-${closingId}` : null, kandangId
() => ClosingApi.getPenjualan(Number(closingId)) ? `sales-${closingId}-${kandangId}`
: closingId
? `sales-${closingId}`
: null,
() =>
kandangId
? ClosingApi.getPenjualanByKandang(Number(closingId), Number(kandangId))
: ClosingApi.getPenjualan(Number(closingId))
); );
const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR( const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR(
closingId ? `hpp-ekspedisi-${closingId}` : null, kandangId
() => ClosingApi.getHppEkspedisi(Number(closingId)) ? `hpp-ekspedisi-${closingId}-${kandangId}`
: closingId
? `hpp-ekspedisi-${closingId}`
: null,
() =>
kandangId
? ClosingApi.getHppEkspedisiByKandang(
Number(closingId),
Number(kandangId)
)
: ClosingApi.getHppEkspedisi(Number(closingId))
); );
if (!closingId) { if (!closingId) {
+3 -1
View File
@@ -38,9 +38,11 @@ const ExpenseEditPage = () => {
!isLoadingExpense && !isLoadingExpense &&
isResponseSuccess(expense) && isResponseSuccess(expense) &&
expense.data.latest_approval.step_number !== 5 && expense.data.latest_approval.step_number !== 5 &&
expense.data.latest_approval.step_number !== 6 &&
(expense.data.latest_approval.step_number === 1 || (expense.data.latest_approval.step_number === 1 ||
expense.data.latest_approval.step_number === 2 || expense.data.latest_approval.step_number === 2 ||
expense.data.latest_approval.step_number === 3); expense.data.latest_approval.step_number === 3 ||
expense.data.latest_approval.step_number === 4);
if (!isLoadingExpense && !isExpenseCanBeEdited) { if (!isLoadingExpense && !isExpenseCanBeEdited) {
router.back(); router.back();
+1 -1
View File
@@ -2,7 +2,7 @@ import ExpensesTable from '@/components/pages/expense/ExpensesTable';
const Expense = () => { const Expense = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full p-4 sm:p-0'>
<ExpensesTable /> <ExpensesTable />
</section> </section>
); );
+2 -2
View File
@@ -38,8 +38,8 @@ const ExpenseRealizationEditPage = () => {
!isLoadingExpense && !isLoadingExpense &&
isResponseSuccess(expense) && isResponseSuccess(expense) &&
expense.data.latest_approval.action !== 'REJECTED' && expense.data.latest_approval.action !== 'REJECTED' &&
(expense.data.latest_approval.step_number === 4 || (expense.data.latest_approval.step_number === 5 ||
expense.data.latest_approval.step_number === 5); expense.data.latest_approval.step_number === 6);
if (!isLoadingExpense && !isExpenseRealizationCanBeEdited) { if (!isLoadingExpense && !isExpenseRealizationCanBeEdited) {
router.back(); router.back();
-2
View File
@@ -24,8 +24,6 @@ const FinanceDetailPage = () => {
); );
} }
console.log(finance);
// if (!finance || isResponseError(finance)) { // if (!finance || isResponseError(finance)) {
// router.replace('/404'); // router.replace('/404');
// return; // return;
+10 -5
View File
@@ -1,5 +1,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin "daisyui"; @plugin "daisyui";
@import '../styles/tailwind.css';
@import '../styles/daisyui.css'; @import '../styles/daisyui.css';
@import '../figma-make/styles/theme.css'; @import '../figma-make/styles/theme.css';
@@ -29,16 +30,16 @@
--color-base-100: oklch(100% 0 0); /* #ffffff */ --color-base-100: oklch(100% 0 0); /* #ffffff */
--color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */ --color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */
--color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */ --color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */
--color-base-content: oklch(18.6% 0.024 257.7); /* #1f2937 */ --color-base-content: #18181b;
/* Status/Utility Colors */ /* Status/Utility Colors */
--color-info: oklch(67.4% 0.176 238.9); --color-info: oklch(67.4% 0.176 238.9);
--color-info-content: oklch(0% 0 0); /* #000000 */ --color-info-content: oklch(0% 0 0); /* #000000 */
--color-success: oklch(62.3% 0.147 149); --color-success: #00d390;
--color-success-content: oklch(100% 0 0); /* #ffffff */ --color-success-content: oklch(100% 0 0); /* #ffffff */
--color-warning: oklch(82.2% 0.165 91.9); --color-warning: #fcb700;
--color-warning-content: oklch(0% 0 0); /* #000000 */ --color-warning-content: oklch(0% 0 0); /* #000000 */
--color-error: oklch(61.8% 0.203 27.8); --color-error: #ff3a3a;
--color-error-content: oklch(100% 0 0); /* #fffffff */ --color-error-content: oklch(100% 0 0); /* #fffffff */
--radius-selector: 0rem; --radius-selector: 0rem;
@@ -52,17 +53,21 @@
} }
:root { :root {
--color-primary: #1f74bf; --color-primary: #0069e0;
} }
@theme { @theme {
--font-inter: var(--font-inter); --font-inter: var(--font-inter);
--font-roboto: var(--font-roboto);
--container-sm: 40rem; --container-sm: 40rem;
--container-md: 48rem; --container-md: 48rem;
--container-lg: 64rem; --container-lg: 64rem;
--container-xl: 80rem; --container-xl: 80rem;
--container-2xl: 96rem; --container-2xl: 96rem;
--shadow-button-soft:
0 3px 2px -2px var(--color-base-200), 0 4px 3px -2px var(--color-base-200);
} }
html { html {
+1 -1
View File
@@ -2,7 +2,7 @@ import MovementTable from '@/components/pages/inventory/movement/MovementTable';
const Movement = () => { const Movement = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full p-4 sm:p-0'>
<MovementTable /> <MovementTable />
</section> </section>
); );
+10 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google'; import { Inter, Roboto } from 'next/font/google';
import '@/app/globals.css'; import '@/app/globals.css';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
@@ -12,6 +12,12 @@ const inter = Inter({
subsets: ['latin'], subsets: ['latin'],
}); });
const roboto = Roboto({
variable: '--font-roboto',
subsets: ['latin'],
weight: ['200', '300', '400', '500', '600', '700', '900'],
});
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: '#1f74bf', themeColor: '#1f74bf',
colorScheme: 'light', colorScheme: 'light',
@@ -30,7 +36,9 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang='en' data-theme='lti'> <html lang='en' data-theme='lti'>
<body className={`${inter.variable} antialiased font-inter`}> <body
className={`${inter.variable} ${roboto.variable} antialiased font-inter`}
>
<RequireAuth> <RequireAuth>
<MainDrawer>{children}</MainDrawer> <MainDrawer>{children}</MainDrawer>
</RequireAuth> </RequireAuth>
+5 -1
View File
@@ -25,5 +25,9 @@ export default function Home() {
); );
} }
return <>Loading...</>; return (
<main className='w-full h-full min-h-screen flex flex-row justify-center items-center'>
<span className='loading loading-spinner loading-lg'></span>
</main>
);
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import RecordingTable from '@/components/pages/production/recording/RecordingTab
const Recording = () => { const Recording = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full p-4 sm:p-0'>
<RecordingTable /> <RecordingTable />
</section> </section>
); );
@@ -1,11 +0,0 @@
import TransferToLayingForm from '@/components/pages/production/transfer-to-laying/form/TransferToLayingForm';
const AddTransferToLaying = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<TransferToLayingForm />
</div>
);
};
export default AddTransferToLaying;
@@ -1,63 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import TransferToLayingForm from '@/components/pages/production/transfer-to-laying/form/TransferToLayingForm';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const TransferToLayingEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const transferToLayingId = searchParams.get('transferToLayingId');
const { data: transferToLaying, isLoading: isLoadingTransferToLaying } =
useSWR(transferToLayingId, (id: number) =>
TransferToLayingApi.getSingle(id)
);
if (!transferToLayingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (
!isLoadingTransferToLaying &&
(!transferToLaying || isResponseError(transferToLaying))
) {
router.replace('/404');
return;
}
if (
isResponseSuccess(transferToLaying) &&
transferToLaying.data.approval.step_number === 2
) {
router.replace('/production/transfer-to-laying');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingTransferToLaying && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && (
<TransferToLayingForm
type='edit'
initialValues={transferToLaying.data}
/>
)}
</div>
);
};
export default TransferToLayingEdit;
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -1,56 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import TransferToLayingForm from '@/components/pages/production/transfer-to-laying/form/TransferToLayingForm';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const TransferToLayingDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const transferToLayingId = searchParams.get('transferToLayingId');
const { data: transferToLaying, isLoading: isLoadingTransferToLaying } =
useSWR(transferToLayingId, (id: number) =>
TransferToLayingApi.getSingle(id)
);
if (!transferToLayingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (
!isLoadingTransferToLaying &&
(!transferToLaying || isResponseError(transferToLaying))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingTransferToLaying && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && (
<TransferToLayingForm
type='detail'
initialValues={transferToLaying.data}
/>
)}
</div>
);
};
export default TransferToLayingDetail;
@@ -1,9 +1,15 @@
import TransferToLayingsTable from '@/components/pages/production/transfer-to-laying/TransferToLayingsTable'; import TransferToLayingsTable from '@/components/pages/production/transfer-to-laying/TransferToLayingsTable';
import TransferToLayingFormModal from '@/components/pages/production/transfer-to-laying/TransferToLayingFormModal';
import TransferToLayingDetailModal from '@/components/pages/production/transfer-to-laying/TransferToLayingDetailModal';
const TransferToLaying = () => { const TransferToLaying = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full'>
<TransferToLayingsTable /> <TransferToLayingsTable />
<TransferToLayingFormModal />
<TransferToLayingDetailModal />
</section> </section>
); );
}; };
+1 -1
View File
@@ -2,7 +2,7 @@ import PurchaseTable from '@/components/pages/purchase/PurchaseTable';
const Purchase = () => { const Purchase = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full p-4 sm:p-0'>
<PurchaseTable /> <PurchaseTable />
</section> </section>
); );
+263
View File
@@ -0,0 +1,263 @@
import React, { useId } from 'react';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import { cn, findMenuPath } from '@/lib/helper';
import { Size } from '@/types/theme';
import Button from '@/components/Button';
import { MAIN_DRAWER_LINKS } from '@/config/constant';
interface BreadcrumbItem {
label: string;
href?: string;
icon?: React.ReactNode;
isActive?: boolean;
isDisabled?: boolean;
}
interface BreadcrumbsProps extends React.HTMLAttributes<HTMLElement> {
items: BreadcrumbItem[];
size?: Size;
maxVisibleItems?: number;
showEllipsisDropdown?: boolean;
}
export function buildBreadcrumbs(pathname: string): BreadcrumbItem[] {
const menuPath = findMenuPath(MAIN_DRAWER_LINKS, pathname);
if (!menuPath) return [];
return menuPath.map((menu, index) => {
const isLast = index === menuPath.length - 1;
return {
label: menu.text,
href: isLast ? menu.link : undefined,
isActive: isLast,
icon: menu.icon ? (
<Icon icon={menu.icon} width={16} height={16} />
) : undefined,
};
});
}
const EllipsisDropdown = ({
hiddenItems,
}: {
hiddenItems: BreadcrumbItem[];
}) => {
const dropdownId = useId();
const anchorId = useId();
return (
<li>
{/* Ellipsis Button */}
<Button
popoverTarget={dropdownId}
variant='ghost'
color='none'
style={
{
anchorName: `--breadcrumb-ellipsis-anchor-${anchorId}`,
} as React.CSSProperties
}
>
<Icon icon='material-symbols:more-horiz' width={16} height={16} />
</Button>
{/* Dropdown Menu using popover API */}
<ul
className='dropdown menu rounded-box bg-base-100 border border-base-300 shadow-lg z-[9999] [&_a:hover]:no-underline [&_a:focus]:no-underline [&&]:no-underline [&&_a]:no-underline [&&]:hover:no-underline [&&]:flex [&&]:items-start [&&]:justify-start w-max'
popover='auto'
id={dropdownId}
style={
{
positionAnchor: `--breadcrumb-ellipsis-anchor-${anchorId}`,
} as React.CSSProperties
}
>
{hiddenItems.map((item, index) => {
const itemStyles = cn(
'[&]:flex [&]:items-center [&]:justify-start py-1 text-sm',
// Disabled state
item.isDisabled && 'text-base-content/40 opacity-50',
// Active/Last state
(item.isActive || item.isDisabled) && 'text-primary',
// Regular clickable state
!item.isDisabled && 'text-base-content/50'
);
const itemContent = (
<div className={itemStyles}>
{item.icon && (
<span className='inline-flex mr-2'>{item.icon}</span>
)}
{item.label}
</div>
);
return (
<li
key={`ellipsis-${index}`}
className='[&&]:text-left [&&]:block w-full'
>
{item.href && !item.isDisabled ? (
<Link
href={item.href}
className='block !no-underline [&&]:text-left w-full'
onClick={(e) => e.stopPropagation()}
>
{itemContent}
</Link>
) : (
<div className='block !no-underline [&&]:cursor-default [&&]:hover:cursor-default [&&]:hover:bg-base-100 [&&]:text-left'>
{itemContent}
</div>
)}
</li>
);
})}
</ul>
</li>
);
};
const Breadcrumb = ({
items,
size = 'md',
maxVisibleItems = 3,
showEllipsisDropdown = true,
className,
...props
}: BreadcrumbsProps) => {
const sizeClasses = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
};
const getItemStyles = (
item: BreadcrumbItem,
position: 'first' | 'middle' | 'last' = 'middle'
) => {
const baseClasses = 'inline-flex items-center gap-2';
// Disabled state
if (item.isDisabled) {
return `${baseClasses} text-base-content/40 !cursor-default opacity-50 hover:!no-underline`;
}
// Active/Last state (no underline)
if (item.isActive || position === 'last') {
return `${baseClasses} text-primary !cursor-pointer hover:!no-underline`;
}
// Regular clickable state
return `${baseClasses} text-base-content/60`;
};
const renderItem = (
item: BreadcrumbItem,
position: 'first' | 'middle' | 'last' = 'middle'
) => {
const styles = getItemStyles(item, position);
// Disabled items
if (item.isDisabled) {
return (
<span className={styles}>
{item.icon && item.icon}
{item.label}
</span>
);
}
// Active/Last items
if (item.isActive || position === 'last') {
if (item.href) {
return (
<Link href={item.href} className={styles}>
{item.icon && (
<span className='inline-flex gap-2'>{item.icon}</span>
)}
{item.label}
</Link>
);
}
return (
<span className={styles}>
{item.icon && item.icon}
{item.label}
</span>
);
}
// Regular items
if (item.href) {
return (
<Link href={item.href} className={styles}>
{item.icon && <span className='inline-flex gap-2'>{item.icon}</span>}
{item.label}
</Link>
);
}
return (
<span className={styles}>
{item.icon && item.icon}
{item.label}
</span>
);
};
const renderBreadcrumbList = () => {
// Show all items if within limit
if (items.length <= maxVisibleItems) {
return items.map((item, index) => {
const position =
index === 0
? 'first'
: index === items.length - 1
? 'last'
: 'middle';
return <li key={index}>{renderItem(item, position)}</li>;
});
}
// Collapsed items indexing when exceeding limit
const firstItem = items[0];
const lastItem = items[items.length - 1];
const visibleMiddleItems = items.slice(1, -1).slice(-(maxVisibleItems - 2));
const hiddenItems = items.slice(1, -1).slice(0, -(maxVisibleItems - 2));
const showEllipsis = showEllipsisDropdown && hiddenItems.length > 0;
return (
<>
<li>{renderItem(firstItem, 'first')}</li>
{/* Ellipsis for hidden items with dropdown */}
{showEllipsis && <EllipsisDropdown hiddenItems={hiddenItems} />}
{/* Middle items */}
{visibleMiddleItems.map((item, index) => (
<li key={`middle-${index}`}>{renderItem(item, 'middle')}</li>
))}
<li>{renderItem(lastItem, 'last')}</li>
</>
);
};
return (
<nav
aria-label='Breadcrumb'
className={cn('breadcrumbs', sizeClasses[size], className)}
{...props}
>
<ul className='text-sm'>{renderBreadcrumbList()}</ul>
</nav>
);
};
export default Breadcrumb;
+2 -1
View File
@@ -2,11 +2,12 @@ import react from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { UrlObject } from 'url';
export interface ButtonProps extends react.ComponentProps<'button'> { export interface ButtonProps extends react.ComponentProps<'button'> {
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active'; variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
color?: Color; color?: Color;
href?: string; href?: string | UrlObject;
isLoading?: boolean; isLoading?: boolean;
target?: string; target?: string;
rel?: string; rel?: string;
+17 -3
View File
@@ -22,6 +22,7 @@ export interface CardProps
onCollapsedChange?: (collapsed: boolean) => void; onCollapsedChange?: (collapsed: boolean) => void;
className?: { className?: {
wrapper?: string; wrapper?: string;
wrapperContent?: string;
image?: string; image?: string;
body?: string; body?: string;
title?: string; title?: string;
@@ -122,6 +123,10 @@ const Card = ({
return cn(baseClasses, 'p-6', className?.body); return cn(baseClasses, 'p-6', className?.body);
}; };
const getCollapsibleClasses = () => {
return cn('', className?.collapsible);
};
const getTitleClasses = () => { const getTitleClasses = () => {
const sizeClasses = { const sizeClasses = {
sm: 'text-lg', sm: 'text-lg',
@@ -144,11 +149,19 @@ const Card = ({
return cn('border-t border-base-300 mt-4 pt-4', className?.footer); return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
}; };
const getWrapperContentClasses = () => {
return cn('space-y-4', className?.wrapperContent);
};
const renderCardContent = () => { const renderCardContent = () => {
const hasContent = children || actions || footer; const hasContent = children || actions || footer;
const titleContent = ( const titleContent = (
<div className='group flex items-center !justify-between w-full'> <div
className={
`group flex items-center justify-between! w-full` + getTitleClasses()
}
>
<div className='flex-1'> <div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>} {title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>} {subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
@@ -156,7 +169,7 @@ const Card = ({
{collapsible && ( {collapsible && (
<button <button
onClick={() => handleCollapsedChange(!isCollapsed)} onClick={() => handleCollapsedChange(!isCollapsed)}
className='btn btn-ghost btn-sm btn-circle' className={`btn btn-ghost btn-sm btn-circle` + getTitleClasses()}
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'} aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
> >
<Icon <Icon
@@ -173,7 +186,7 @@ const Card = ({
); );
const cardContent = ( const cardContent = (
<div className='space-y-4'> <div className={getWrapperContentClasses()}>
{children} {children}
{actions && <div className={getActionsClasses()}>{actions}</div>} {actions && <div className={getActionsClasses()}>{actions}</div>}
{footer && <div className={getFooterClasses()}>{footer}</div>} {footer && <div className={getFooterClasses()}>{footer}</div>}
@@ -204,6 +217,7 @@ const Card = ({
titleClassName='w-full cursor-pointer' titleClassName='w-full cursor-pointer'
contentClassName='p-0' contentClassName='p-0'
fullWidth={true} fullWidth={true}
className={getCollapsibleClasses()}
> >
{cardContent} {cardContent}
</Collapse> </Collapse>
+1 -1
View File
@@ -162,7 +162,7 @@ const Drawer = ({
<div <div
className={cn( className={cn(
varianClassName?.drawerSidebarContent, varianClassName?.drawerSidebarContent,
className?.drawerContent, className?.drawerSidebarContent,
'overflow-y-auto' 'overflow-y-auto'
)} )}
> >
+23 -48
View File
@@ -26,29 +26,34 @@ const MainDrawerContent = () => {
}; };
return ( return (
<div className='w-full p-4 flex flex-col gap-4'> <div className='w-full flex flex-col'>
<div className='flex flex-row items-center gap-4'> <div className='p-3 flex flex-row items-center gap-4 border-b border-base-content/10'>
<Image <div className='flex flex-row items-center gap-2'>
src='/assets/img/lti-logo.png' <Image
alt='MBU Logo' src='/assets/img/lti-logo.png'
width={256} alt='LTI Logo'
height={256} width={40}
className='w-full max-w-16 h-auto' height={40}
/> className='w-full max-w-10 h-auto'
/>
<h1 className='text-xl font-bold'>LTI ERP</h1> <div className='font-roboto'>
<h1 className='text-sm font-semibold'>LTI ERP</h1>
<p className='text-sm text-black/50'>Lumbung Telur Indonesia</p>
</div>
</div>
<div className='grow flex flex-row justify-end sm:hidden'> <div className='grow flex flex-row justify-end sm:hidden'>
<Button <Button
variant='soft' variant='soft'
color='error' color='error'
onClick={closeMainDrawerHandler} onClick={closeMainDrawerHandler}
className='rounded-full' className='p-1 rounded-full'
> >
<Icon <Icon
icon='material-symbols:close-rounded' icon='material-symbols:close-rounded'
width={24} width={16}
height={24} height={16}
/> />
</Button> </Button>
</div> </div>
@@ -73,40 +78,6 @@ const MainDrawer = ({
permissionCheck(permission) permissionCheck(permission)
); );
const getPageTitle = useCallback(() => {
let title = '';
const activeMenu = MAIN_DRAWER_LINKS.find((item) =>
isPathActive(pathname, item.link)
);
const traverseMenuTitle = (menu: typeof activeMenu) => {
if (!menu) return;
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
if (!title) {
title += menu?.text;
} else {
title += ' - ' + menu?.text;
}
if (!hasSubmenu || !menu.submenu) return;
const activeSubmenu = menu.submenu?.find((item) =>
isPathActive(pathname, item.link)
);
traverseMenuTitle(activeSubmenu);
};
traverseMenuTitle(activeMenu);
return title;
}, [pathname]);
const pageTitle = getPageTitle();
const toggleSidebar = () => { const toggleSidebar = () => {
setMainDrawerOpen(!mainDrawerOpen); setMainDrawerOpen(!mainDrawerOpen);
}; };
@@ -121,9 +92,13 @@ const MainDrawer = ({
setOpen={setMainDrawerOpen} setOpen={setMainDrawerOpen}
openOnLarge openOnLarge
sidebarContent={<MainDrawerContent />} sidebarContent={<MainDrawerContent />}
className={{
drawerSide: 'border-r border-base-content/10',
drawerSidebarContent: 'min-w-[244px] lg:w-[244px]',
}}
> >
<main className='w-full h-full flex flex-col'> <main className='w-full h-full flex flex-col'>
<Navbar title={pageTitle as string} toggleSidebar={toggleSidebar} /> <Navbar toggleSidebar={toggleSidebar} />
{children} {children}
</main> </main>
+22 -2
View File
@@ -53,15 +53,25 @@ interface ModalProps {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
children?: ReactNode; children?: ReactNode;
closeOnBackdrop?: boolean; closeOnBackdrop?: boolean;
onBackdropClick?: () => void;
position?: 'top' | 'middle' | 'bottom' | 'start' | 'end';
className?: { className?: {
modal?: string; modal?: string;
modalBox?: string; modalBox?: string;
}; };
} }
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { const Modal = ({
ref,
children,
closeOnBackdrop,
onBackdropClick,
position = 'middle',
className,
}: ModalProps) => {
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => { const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (closeOnBackdrop && e.target === ref.current) { if (closeOnBackdrop && e.target === ref.current) {
onBackdropClick?.();
ref.current?.close(); ref.current?.close();
} }
}; };
@@ -69,7 +79,17 @@ const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
return ( return (
<dialog <dialog
ref={ref} ref={ref}
className={cn('modal', className?.modal)} className={cn(
'modal',
{
'modal-top': position === 'top',
'modal-middle': position === 'middle',
'modal-bottom': position === 'bottom',
'modal-start': position === 'start',
'modal-end': position === 'end',
},
className?.modal
)}
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className={cn('modal-box', className?.modalBox)}>{children}</div> <div className={cn('modal-box', className?.modalBox)}>{children}</div>
+46 -32
View File
@@ -1,26 +1,28 @@
'use client'; 'use client';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown'; import Breadcrumb, { buildBreadcrumbs } from '@/components/Breadcrumb';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth'; import { AuthApi } from '@/services/api/auth';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { useUiStore } from '@/stores/ui/ui.store';
interface NavbarProps { interface NavbarProps {
title: string;
toggleSidebar?: () => void; toggleSidebar?: () => void;
} }
const Navbar = ({ title, toggleSidebar }: NavbarProps) => { const Navbar = ({ toggleSidebar }: NavbarProps) => {
const { setUser } = useAuth(); const { setUser } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const navbarActions = useUiStore((state) => state.navbarActions);
const logoutClickHandler = async () => { const logoutClickHandler = async () => {
const logoutRes = await AuthApi.logout(); const logoutRes = await AuthApi.logout();
@@ -35,42 +37,54 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
}; };
return ( return (
<div className='navbar px-4 bg-base-100 shadow-sm'> <div className='navbar p-3 bg-base-100 border-b border-base-content/10'>
<div className='flex-1'> <div className='flex-1'>
<div className='flex flex-row items-center gap-4'> <div className='flex flex-row items-center gap-4'>
{toggleSidebar && ( {toggleSidebar && (
<Button onClick={toggleSidebar} className='block lg:hidden'> <Button
<Icon variant='ghost'
icon='material-symbols:menu-rounded' color='none'
width={24} onClick={toggleSidebar}
height={24} className='block lg:hidden p-[9px] text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
/> >
<Icon icon='heroicons:bars-3' width={20} height={20} />
</Button> </Button>
)} )}
<span className='font-bold text-xl text-primary'>{title}</span> <Breadcrumb items={buildBreadcrumbs(pathname)} />
</div> </div>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2 items-center'>
<Dropdown {/* Page-specific actions */}
align='end' {navbarActions && <div className='mr-2'>{navbarActions}</div>}
direction='bottom' <PopoverButton
trigger={ tabIndex={0}
<div className='btn btn-ghost btn-circle avatar'> variant='ghost'
<div className='w-10 rounded-full border flex justify-center items-center'> color='none'
<Icon icon='uil:user' width={40} height={40} /> popoverTarget='accountNavbar'
</div> anchorName='--account-navbar'
</div> className='p-[9px] text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
}
className={{
content: 'w-52 mt-3',
}}
> >
<Menu> <Icon icon='heroicons:user' width={20} height={20} />
<MenuItem title='Logout' onClick={logoutClickHandler} /> </PopoverButton>
</Menu>
</Dropdown> <PopoverContent
id='accountNavbar'
anchorName='--account-navbar'
position='bottom-start'
className='rounded-xl border border-base-content/5 shadow-sm'
>
<Button
onClick={logoutClickHandler}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full'
>
<Icon icon='heroicons-outline:logout' width={20} height={20} />
Logout
</Button>
</PopoverContent>
</div> </div>
</div> </div>
); );
+180 -49
View File
@@ -1,11 +1,12 @@
'use client'; 'use client';
import { ReactNode, useCallback, useEffect, useState } from 'react'; import { Fragment, ReactNode, useCallback, useEffect, useState } from 'react';
import { import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getPaginationRowModel, getPaginationRowModel,
getExpandedRowModel,
getSortedRowModel, getSortedRowModel,
TableOptions, TableOptions,
useReactTable, useReactTable,
@@ -15,6 +16,7 @@ import {
OnChangeFn, OnChangeFn,
Row, Row,
HeaderContext, HeaderContext,
ExpandedState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { rankItem } from '@tanstack/match-sorter-utils'; import { rankItem } from '@tanstack/match-sorter-utils';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -31,11 +33,16 @@ interface TableClassNames {
headerColumnClassName?: string; headerColumnClassName?: string;
tableBodyClassName?: string; tableBodyClassName?: string;
bodyRowClassName?: string; bodyRowClassName?: string;
selectedBodyRowClassName?: string;
bodyColumnClassName?: string; bodyColumnClassName?: string;
bodySubRowClassName?: (depth: number) => string;
selectedBodySubRowClassName?: (depth: number) => string;
bodySubRowColumnClassName?: (depth: number) => string;
tableFooterClassName?: string; tableFooterClassName?: string;
footerRowClassName?: string; footerRowClassName?: string;
footerColumnClassName?: string; footerColumnClassName?: string;
paginationClassName?: string; paginationClassName?: string;
skeletonCellClassName?: string;
} }
export interface TableProps<TData extends object> { export interface TableProps<TData extends object> {
@@ -59,6 +66,7 @@ export interface TableProps<TData extends object> {
enableRowSelection?: boolean | ((row: Row<TData>) => boolean); enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
renderFooter?: boolean; renderFooter?: boolean;
withCheckbox?: boolean; withCheckbox?: boolean;
withPagination?: boolean;
rowOptions?: number[]; rowOptions?: number[];
/** /**
* Custom row renderer. Should return a complete <tr> element or null. * Custom row renderer. Should return a complete <tr> element or null.
@@ -66,9 +74,15 @@ export interface TableProps<TData extends object> {
* Return null to render the default row. * Return null to render the default row.
*/ */
renderCustomRow?: (row: Row<TData>) => ReactNode | null; renderCustomRow?: (row: Row<TData>) => ReactNode | null;
getRowCanExpand?: (row: Row<TData>) => boolean;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
expanded?: ExpandedState;
getSubRows?: (originalRow: TData, index: number) => TData[] | undefined;
} }
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; const DUMMY_SKELETON_DATA = Array.from({ length: 10 }, (_, index) => ({
id: index,
}));
const emptyContentDefaultValue = ( const emptyContentDefaultValue = (
<div className='w-full p-5 text-center'> <div className='w-full p-5 text-center'>
@@ -86,11 +100,18 @@ export const TABLE_DEFAULT_STYLING = {
tableHeaderClassName: '', tableHeaderClassName: '',
headerRowClassName: '', headerRowClassName: '',
headerColumnClassName: headerColumnClassName:
'px-4 py-3 border-base-content/10 text-base-content/50', 'px-4 py-3 border-base-content/10 text-base-content/50 text-sm font-medium',
tableBodyClassName: '', tableBodyClassName: '',
bodyRowClassName: 'border-t border-base-content/10', bodyRowClassName:
bodyColumnClassName: 'px-4 py-3 text-base-content', 'transition-all duration-200 border-t border-base-content/10 bg-transparent',
paginationClassName: '', selectedBodyRowClassName: 'bg-primary/5',
bodyColumnClassName: 'px-4 py-3 text-base-content font-medium',
bodySubRowClassName: (depth: number) =>
'transition-all duration-200 border-t border-base-content/10 bg-transparent',
selectedBodySubRowClassName: (depth: number) => 'bg-primary/5',
bodySubRowColumnClassName: (depth: number) =>
'px-4 py-3 text-base-content font-medium',
paginationClassName: 'px-3',
tableFooterClassName: 'font-semibold border-base-content/10', tableFooterClassName: 'font-semibold border-base-content/10',
footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10', footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10',
footerColumnClassName: 'p-4 text-base-content whitespace-nowrap', footerColumnClassName: 'p-4 text-base-content whitespace-nowrap',
@@ -117,8 +138,13 @@ const Table = <TData extends object>({
enableRowSelection, enableRowSelection,
renderFooter = false, renderFooter = false,
withCheckbox = false, withCheckbox = false,
withPagination = true,
rowOptions = [10, 20, 50, 100], rowOptions = [10, 20, 50, 100],
renderCustomRow, renderCustomRow,
getRowCanExpand,
renderSubComponent,
expanded = {},
getSubRows,
}: TableProps<TData>) => { }: TableProps<TData>) => {
const isServerSideTable = const isServerSideTable =
totalItems !== undefined && totalItems !== undefined &&
@@ -151,10 +177,14 @@ const Table = <TData extends object>({
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination, onPaginationChange: setPagination,
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: getRowCanExpand ?? (getSubRows ? undefined : () => false),
getSubRows,
manualSorting, manualSorting,
state: { state: {
pagination, pagination,
globalFilter: fuzzySearchValue, globalFilter: fuzzySearchValue,
expanded,
}, },
filterFns: { filterFns: {
fuzzy: fuzzyFilter, fuzzy: fuzzyFilter,
@@ -222,14 +252,40 @@ const Table = <TData extends object>({
}, [pageSize, setPageSize]); }, [pageSize, setPageSize]);
return ( return (
<div className={tableClassNames.containerClassName}> <div
<div className={tableClassNames.tableWrapperClassName}> className={cn(
<table className={tableClassNames.tableClassName}> TABLE_DEFAULT_STYLING.containerClassName,
<thead className={tableClassNames.tableHeaderClassName}> tableClassNames.containerClassName,
{
'mb-0': !withPagination,
}
)}
>
<div
className={cn(
TABLE_DEFAULT_STYLING.tableWrapperClassName,
tableClassNames.tableWrapperClassName
)}
>
<table
className={cn(
TABLE_DEFAULT_STYLING.tableClassName,
tableClassNames.tableClassName
)}
>
<thead
className={cn(
TABLE_DEFAULT_STYLING.tableHeaderClassName,
tableClassNames.tableHeaderClassName
)}
>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr <tr
key={headerGroup.id} key={headerGroup.id}
className={tableClassNames.headerRowClassName} className={cn(
TABLE_DEFAULT_STYLING.headerRowClassName,
tableClassNames.headerRowClassName
)}
> >
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const columnRelativeDepth = const columnRelativeDepth =
@@ -262,6 +318,7 @@ const Table = <TData extends object>({
{ {
'border-b': header.colSpan > 1, 'border-b': header.colSpan > 1,
}, },
TABLE_DEFAULT_STYLING.headerColumnClassName,
tableClassNames.headerColumnClassName tableClassNames.headerColumnClassName
)} )}
> >
@@ -311,7 +368,12 @@ const Table = <TData extends object>({
))} ))}
</thead> </thead>
<tbody className={tableClassNames.tableBodyClassName}> <tbody
className={cn(
TABLE_DEFAULT_STYLING.tableBodyClassName,
tableClassNames.tableBodyClassName
)}
>
{table.getRowModel().rows.map((row) => { {table.getRowModel().rows.map((row) => {
const customRowContent = renderCustomRow?.(row); const customRowContent = renderCustomRow?.(row);
@@ -320,36 +382,96 @@ const Table = <TData extends object>({
} }
return ( return (
<tr key={row.id} className={tableClassNames.bodyRowClassName}> <Fragment key={row.id}>
{row.getVisibleCells().map((cell) => ( <tr
<td data-depth={row.depth}
key={cell.id} className={cn(
className={cn( row.depth > 0
{ 'first:w-9 first:pr-0': withCheckbox }, ? tableClassNames.bodySubRowClassName(row.depth)
tableClassNames.bodyColumnClassName : tableClassNames.bodyRowClassName,
)} {
> [tableClassNames.selectedBodyRowClassName!]:
{!isLoading && row.getIsSelected() && row.depth === 0,
flexRender( [tableClassNames.selectedBodySubRowClassName(
cell.column.columnDef.cell, row.depth
cell.getContext() )!]: row.getIsSelected() && row.depth > 0,
}
)}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
TABLE_DEFAULT_STYLING.bodyColumnClassName,
row.depth > 0
? tableClassNames.bodySubRowColumnClassName(
row.depth
)
: tableClassNames.bodyColumnClassName
)} )}
>
{!isLoading &&
flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{isLoading && <div className='skeleton w-full h-4' />} {isLoading && (
</td> <div
))} className={cn(
</tr> 'skeleton w-full h-4',
tableClassNames.skeletonCellClassName
)}
/>
)}
</td>
))}
</tr>
{row.getIsExpanded() && (
<>
{renderSubComponent && (
<tr
className={cn(
TABLE_DEFAULT_STYLING.bodySubRowClassName(1),
tableClassNames.bodySubRowClassName(1),
{
[tableClassNames.selectedBodySubRowClassName(1)]:
row.getIsSelected(),
}
)}
>
<td colSpan={row.getVisibleCells().length}>
{renderSubComponent({ row })}
</td>
</tr>
)}
</>
)}
</Fragment>
); );
})} })}
</tbody> </tbody>
<tfoot className={cn(tableClassNames.tableFooterClassName)}> <tfoot
className={cn(
TABLE_DEFAULT_STYLING.tableFooterClassName,
tableClassNames.tableFooterClassName
)}
>
{renderFooter && ( {renderFooter && (
<tr className={cn(tableClassNames.footerRowClassName)}> <tr
className={cn(
TABLE_DEFAULT_STYLING.footerRowClassName,
tableClassNames.footerRowClassName
)}
>
{table.getAllLeafColumns().map((column) => ( {table.getAllLeafColumns().map((column) => (
<td <td
key={column.id} key={column.id}
className={cn( className={cn(
{ 'first:w-9 first:pr-0': withCheckbox }, { 'first:w-9 first:pr-0': withCheckbox },
TABLE_DEFAULT_STYLING.footerColumnClassName,
tableClassNames.footerColumnClassName tableClassNames.footerColumnClassName
)} )}
> >
@@ -371,24 +493,33 @@ const Table = <TData extends object>({
!isLoading && !isLoading &&
emptyContent} emptyContent}
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && ( {data.length > 0 &&
<div className={cn('mt-5', tableClassNames.paginationClassName)}> table.getRowModel().rows.length > 0 &&
<Pagination !isLoading &&
totalItems={isServerSideTable ? totalItems : table.getRowCount()} withPagination && (
itemsPerPage={table.getState().pagination.pageSize} <div
currentPage={ className={cn(
isServerSideTable 'mt-5',
? page TABLE_DEFAULT_STYLING.paginationClassName,
: table.getState().pagination.pageIndex + 1 tableClassNames.paginationClassName
} )}
onPrevPage={prevPageClickHandler} >
onNextPage={nextPageClickHandler} <Pagination
onPageChange={pageChangeHandler} totalItems={isServerSideTable ? totalItems : table.getRowCount()}
rowOptions={rowOptions} itemsPerPage={table.getState().pagination.pageSize}
onRowChange={onPageSizeChange} currentPage={
/> isServerSideTable
</div> ? page
)} : table.getState().pagination.pageIndex + 1
}
onPrevPage={prevPageClickHandler}
onNextPage={nextPageClickHandler}
onPageChange={pageChangeHandler}
rowOptions={rowOptions}
onRowChange={onPageSizeChange}
/>
</div>
)}
</div> </div>
); );
}; };
+23 -12
View File
@@ -25,8 +25,10 @@ export interface TabsProps
wrapper?: string; wrapper?: string;
tab?: string; tab?: string;
content?: string; content?: string;
tabHeaderWrapper?: string;
}; };
onTabChange?: (tabId: string) => void; onTabChange?: (tabId: string) => void;
sideContent?: ReactNode;
} }
const Tabs = ({ const Tabs = ({
@@ -38,6 +40,7 @@ const Tabs = ({
activeTabId: controlledActiveId, activeTabId: controlledActiveId,
className, className,
onTabChange, onTabChange,
sideContent,
...props ...props
}: TabsProps) => { }: TabsProps) => {
// State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset // State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset
@@ -59,6 +62,7 @@ const Tabs = ({
wrapper: wrapperClassName, wrapper: wrapperClassName,
tab: tabClassName, tab: tabClassName,
content: contentClassName, content: contentClassName,
tabHeaderWrapper: tabHeaderWrapperClassName,
} = typeof className === 'object' } = typeof className === 'object'
? className ? className
: { wrapper: className, tab: undefined }; : { wrapper: className, tab: undefined };
@@ -102,6 +106,10 @@ const Tabs = ({
tabClassName tabClassName
); );
const getSideContentClasses = () => {
return cn('flex flex-row', tabHeaderWrapperClassName);
};
const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content; const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content;
return ( return (
@@ -112,18 +120,21 @@ const Tabs = ({
typeof className === 'string' ? className : containerClassName typeof className === 'string' ? className : containerClassName
)} )}
> >
<div role='tablist' className={getTabsClasses()}> <div className={getSideContentClasses()}>
{tabs.map(({ id, label, disabled }) => ( <div role='tablist' className={getTabsClasses()}>
<button {tabs.map(({ id, label, disabled }) => (
key={id} <button
role='tab' key={id}
className={getTabClasses(id === activeTabId, disabled)} role='tab'
onClick={() => !disabled && handleTabChange(id)} className={getTabClasses(id === activeTabId, disabled)}
disabled={disabled} onClick={() => !disabled && handleTabChange(id)}
> disabled={disabled}
{label} >
</button> {label}
))} </button>
))}
</div>
{sideContent && sideContent}
</div> </div>
{activeContent && ( {activeContent && (
+203
View File
@@ -0,0 +1,203 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import { BaseApproval } from '@/types/api/api-general';
import Button from '@/components/Button';
import { cn, formatDate } from '@/lib/helper';
interface ApprovalStepsV2Props {
approvals?: BaseApproval[];
steps: {
step_number: number;
step_name: string;
}[];
maxVisibleSteps?: number;
className?: {
wrapper?: string;
stepsWrapper?: string;
stepsContainer?: string;
};
}
const ApprovalStepsV2 = ({
approvals,
steps,
maxVisibleSteps = 2,
className,
}: ApprovalStepsV2Props) => {
const [isSeeAll, setIsSeeAll] = useState(false);
const [formattedApprovals, setFormattedApprovals] = useState<
(BaseApproval & { isActive: boolean })[]
>([]);
const latestApprovalStepNumber =
approvals?.[approvals.length - 1].step_number ?? 0;
const lastStepNumber = steps[steps.length - 1].step_number;
const isLatestApprovalStepNumberLessThanLastStepNumber =
latestApprovalStepNumber < lastStepNumber;
const slicedFormattedApprovals = useMemo(() => {
return formattedApprovals.slice(0, isSeeAll ? undefined : maxVisibleSteps);
}, [formattedApprovals, isSeeAll]);
const seeMoreClickHandler = () => {
setIsSeeAll((prevVal) => !prevVal);
};
useEffect(() => {
if (approvals) {
const tempFormattedApprovals: (BaseApproval & { isActive: boolean })[] =
[];
approvals.forEach((approval) => {
tempFormattedApprovals.push({
...approval,
isActive: true,
});
});
if (isLatestApprovalStepNumberLessThanLastStepNumber) {
const latestApprovalStepNumberIndexInSteps = steps.findIndex(
(step) => step.step_number === latestApprovalStepNumber
);
const slicedSteps = steps.slice(
latestApprovalStepNumberIndexInSteps + 1
);
slicedSteps.forEach((step) => {
tempFormattedApprovals.push({
action: 'APPROVED',
action_at: new Date().toISOString(),
action_by: {
id: 0,
id_user: 0,
email: '',
name: '',
},
step_name: step.step_name,
step_number: step.step_number,
isActive: false,
});
});
}
setFormattedApprovals(tempFormattedApprovals);
}
}, [approvals]);
return (
<div
className={cn(
'w-full p-4 flex flex-col border-b border-base-content/10',
className?.wrapper
)}
>
<h4 className='text-base font-medium text-base-content/50 font-roboto'>
Progress Details
</h4>
<div
className={cn(
'mt-6 mb-8 flex flex-col gap-10',
className?.stepsWrapper
)}
>
{slicedFormattedApprovals.map((approval, idx) => {
const isApprovalActionCreated = approval.action === 'CREATED';
const isApprovalActionUpdated = approval.action === 'UPDATED';
const isApprovalActionRejected = approval.action === 'REJECTED';
const isApprovalActionApproved = approval.action === 'APPROVED';
const approvalIcon =
isApprovalActionCreated || isApprovalActionUpdated
? 'heroicons:clock-solid'
: isApprovalActionRejected
? 'heroicons:x-circle-solid'
: isApprovalActionApproved
? 'heroicons:check-badge-solid'
: 'heroicons:check-badge-solid';
return (
<div key={idx} className='w-full flex flex-row items-stretch gap-3'>
<div className='w-fit self-stretch relative'>
<div className='w-fit h-fit flex flex-col items-start'>
<Icon
icon={approvalIcon}
width={24}
height={24}
className={cn({
'text-warning':
isApprovalActionCreated || isApprovalActionUpdated,
'text-error': isApprovalActionRejected,
'text-success': isApprovalActionApproved,
'text-base-content/20': !approval.isActive,
})}
/>
{idx < formattedApprovals.length - 1 && (
<div className='absolute top-6 left-1/2 -translate-x-1/2 w-0 min-h-full h-[calc(100%)] mx-auto my-2 border border-dashed border-base-content/10' />
)}
</div>
</div>
<div
className={cn('w-full flex flex-col gap-1 text-base-content', {
'text-base-content/20': !approval.isActive,
})}
>
<div className='flex flex-col'>
<span className='text-xs'>{approval.step_name}</span>
<span className='text-sm font-semibold'>
{(isApprovalActionCreated || isApprovalActionUpdated) &&
'Diajukan oleh '}
{isApprovalActionRejected && 'Ditolak oleh '}
{isApprovalActionApproved && 'Disetujui oleh '}
{approval.isActive ? approval.action_by.name : '...'}
</span>
</div>
{approval.isActive && (
<p className='w-full max-w-60 p-3 bg-base-content/5 rounded-xl text-xs text-base-content/50'>
Created at :{' '}
{formatDate(approval.action_at, 'DD-MM-YYYY, HH:mm')}
<br />
Notes : {approval.notes ?? '-'}
</p>
)}
</div>
</div>
);
})}
</div>
{formattedApprovals.length > maxVisibleSteps && (
<Button
variant='outline'
color='none'
onClick={seeMoreClickHandler}
className={cn(
'px-3 py-2 gap-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-lg transition-all'
)}
>
<Icon
icon='heroicons-outline:chevron-double-down'
width={20}
height={20}
className={cn('transition-all duration-300', {
'-rotate-180': isSeeAll,
})}
/>
See {isSeeAll ? 'Less' : 'More'}
</Button>
)}
</div>
);
};
export default ApprovalStepsV2;
+48
View File
@@ -0,0 +1,48 @@
import Button, { ButtonProps } from '@/components/Button';
import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
import { FormikValues } from 'formik';
export type ButtonFilterProps = ButtonProps & {
values: FormikValues;
onClick: () => void;
};
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
return (
<Button
{...props}
onClick={onClick}
variant='outline'
color='none'
className={cn(
'rounded-lg max-h-10 font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
getFilledFormikValuesCount(values) > 0
? 'border-primary-gradient text-primary rounded-lg!'
: 'rounded-lg',
props.className
)}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
getFilledFormikValuesCount(values) > 0 ? 'text-blue-600' : ''
}
/>
Filter
{getFilledFormikValuesCount(values) > 0 && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{getFilledFormikValuesCount(values)}
</span>
)}
</Button>
);
};
export default ButtonFilter;
+4 -2
View File
@@ -72,8 +72,10 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
await AuthApi.refresh(); await AuthApi.refresh();
}; };
refreshUserSession(); if (user) {
}, []); refreshUserSession();
}
}, [user]);
if ( if (
(isLoadingUserResponse && !userResponse && !userErrorResponse) || (isLoadingUserResponse && !userResponse && !userErrorResponse) ||
+58
View File
@@ -0,0 +1,58 @@
import Badge from '@/components/Badge';
import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
interface StatusBadgeProps {
color: Color;
text: string;
className?: {
badge?: string;
status?: string;
};
}
const StatusBadge = ({
color = 'neutral',
text,
className,
}: StatusBadgeProps) => {
return (
<Badge
variant='soft'
className={{
badge: cn(
'px-2 py-1 w-full flex flex-row justify-start gap-1 rounded-lg border border-base-content/10 text-xs font-medium text-base-content',
{
'bg-base-content/5': color === 'neutral',
'bg-success/30': color === 'success',
'bg-error/20': color === 'error',
'bg-primary/20': color === 'info',
'bg-[#FF9A20]/12': color === 'warning',
},
className?.badge
),
status: cn(className?.status),
}}
color={color}
>
<svg
height='12'
width='12'
xmlns='http://www.w3.org/2000/svg'
className={cn({
'text-base-content/10': color === 'neutral',
'text-[#008000]': color === 'success',
'text-error': color === 'error',
'text-primary': color === 'info',
'text-[#FF9A20]': color === 'warning',
})}
>
<circle r='6' cx='6' cy='6' fill='currentColor' />
</svg>
{text}
</Badge>
);
};
export default StatusBadge;
@@ -58,6 +58,7 @@ const DrawerHeader = ({
if (leftIconOnClick) { if (leftIconOnClick) {
return ( return (
<button <button
type='button'
onClick={leftIconOnClick} onClick={leftIconOnClick}
className='hover:text-gray-400 bg-transparent border-none p-0' className='hover:text-gray-400 bg-transparent border-none p-0'
> >
@@ -72,12 +73,12 @@ const DrawerHeader = ({
return ( return (
<div <div
className={cn( className={cn(
'flex flex-row justify-between items-center px-4 pt-4', 'flex flex-row justify-between items-center px-4 pt-4 pb-4 border-b border-base-content/10',
className className
)} )}
> >
{/* Left Side */} {/* Left Side */}
<div className='flex flex-row h-full gap-2 items-center'> <div className='flex flex-row h-full gap-3 items-center'>
{renderLeftIcon()} {renderLeftIcon()}
{showDivider && subtitle && ( {showDivider && subtitle && (
@@ -85,7 +86,12 @@ const DrawerHeader = ({
)} )}
{subtitle && ( {subtitle && (
<div className={cn('text-sm text-neutral', subtitleClassName)}> <div
className={cn(
'text-sm font-medium text-base-content/50',
subtitleClassName
)}
>
{subtitle} {subtitle}
</div> </div>
)} )}
+1 -1
View File
@@ -18,7 +18,7 @@ const AlertErrorList = ({
if (formErrorList.length === 0) return null; if (formErrorList.length === 0) return null;
return ( return (
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'> <Alert color='error' className='w-full flex flex-col gap-2 px-4'>
<div className='flex justify-between items-center gap-2 w-full'> <div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} /> <Icon icon='material-symbols:error-outline' width={24} height={24} />
@@ -0,0 +1,45 @@
'use client';
import { View, StyleSheet } from '@react-pdf/renderer';
import { PdfThead, PdfColumn } from './PdfThead';
import { PdfTbody, PdfTbodyCell } from './PdfTbody';
import { PdfTfoot, PdfTfootCell } from './PdfTfoot';
const styles = StyleSheet.create({
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
});
interface PdfTableProps {
columns: PdfColumn[];
data: PdfTbodyCell[][];
footer?: PdfTfootCell[];
footerLabel?: string;
firstRow?: {
valueKey: string;
value: number;
align?: 'right';
color?: string;
};
}
export const PdfTable = ({
columns,
data,
footer,
footerLabel = 'Total',
firstRow,
}: PdfTableProps) => {
return (
<View style={styles.table}>
<PdfThead columns={columns} />
<PdfTbody columns={columns} rows={data} firstRow={firstRow} />
{footer && footer.length > 0 && (
<PdfTfoot columns={columns} cells={footer} label={footerLabel} />
)}
</View>
);
};
@@ -0,0 +1,219 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
export interface PdfTbodyCell {
key: string;
value: string | number | React.ReactNode;
align?: 'left' | 'center' | 'right';
color?: string;
formatAs?: 'text' | 'date' | 'currency' | 'number';
formatDate?: string;
}
const styles = StyleSheet.create({
tableRow: {
flexDirection: 'row',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'left',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 7,
borderRightWidth: 0,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableCellNo: {
flex: 0.5,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
});
interface PdfTbodyProps {
columns: PdfColumn[];
rows: PdfTbodyCell[][];
firstRow?: {
valueKey: string;
value: number;
align?: 'right';
color?: string;
};
formatDate?: (date: string, format: string) => string;
formatNumber?: (num: number) => string;
formatCurrency?: (num: number) => string;
}
export const PdfTbody = ({ columns, rows, firstRow }: PdfTbodyProps) => {
return (
<>
{/* First Row */}
{firstRow && (
<View style={[styles.tableRow, styles.tableBorderBottom]}>
{columns.map((column, index) => {
const isLastColumn = index === columns.length - 1;
const isfirstRowColumn = column.key === firstRow.valueKey;
const align = column.align || 'center';
const cellStyle =
column.key === 'no'
? [styles.tableCellNo, { flex: column.flex }]
: isfirstRowColumn
? [
styles.tableCellRight,
{
flex: column.flex,
color: firstRow.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: align === 'center'
? [
styles.tableCellCenter,
{
flex: column.flex,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [
styles.tableCellLast,
{
flex: column.flex,
borderRightWidth: 0,
},
]
: [styles.tableCell, { flex: column.flex }];
return (
<View key={column.key} style={cellStyle}>
<Text>{isfirstRowColumn ? firstRow.value : ''}</Text>
</View>
);
})}
</View>
)}
{/* Data Rows */}
{rows.map((row, rowIndex) => {
const isLastRow = rowIndex === rows.length - 1;
return (
<View
key={rowIndex}
style={[
styles.tableRow,
!isLastRow ? styles.tableBorderBottom : {},
]}
>
{columns.map((column, colIndex) => {
const cell = row.find((c) => c.key === column.key);
const isLastColumn = colIndex === columns.length - 1;
const align = cell?.align || column.align || 'center';
const cellStyle =
column.key === 'no'
? [styles.tableCellNo, { flex: column.flex }]
: align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
color: cell?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: align === 'center'
? [
styles.tableCellCenter,
{
flex: column.flex,
color: cell?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [
styles.tableCellLast,
{ flex: column.flex, borderRightWidth: 0 },
]
: [
styles.tableCell,
{
flex: column.flex,
color: cell?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
];
return (
<View key={column.key} style={cellStyle}>
{cell?.value !== undefined &&
cell?.value !== null &&
cell?.value !== '' ? (
typeof cell.value === 'object' ? (
cell.value
) : (
<Text>{String(cell.value)}</Text>
)
) : (
<Text>-</Text>
)}
</View>
);
})}
</View>
);
})}
</>
);
};
@@ -0,0 +1,131 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
export interface PdfTfootCell {
key: string;
value: string | number;
align?: 'left' | 'center' | 'right';
flex?: number;
color?: string;
}
const styles = StyleSheet.create({
tableRow: {
flexDirection: 'row',
},
summaryRow: {
backgroundColor: '#F0F0F0',
fontWeight: 'bold',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'left',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 7,
borderRightWidth: 0,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableCellNo: {
flex: 0.5,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
});
interface PdfTfootProps {
columns: PdfColumn[];
cells: PdfTfootCell[];
label?: string;
}
export const PdfTfoot = ({
columns,
cells,
label = 'Total',
}: PdfTfootProps) => {
return (
<View style={[styles.tableRow, styles.summaryRow]}>
{columns.map((column, index) => {
const isLastColumn = index === columns.length - 1;
const cellData = cells.find((c) => c.key === column.key);
const cellStyle =
column.key === 'no'
? [
styles.tableCellNo,
{ flex: column.flex, borderRightWidth: isLastColumn ? 0 : 1 },
]
: cellData?.align === 'right'
? [
styles.tableCellRight,
{
flex: column.flex,
color: cellData?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: cellData?.align === 'center'
? [
styles.tableCellCenter,
{
flex: column.flex,
color: cellData?.color || 'black',
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: isLastColumn
? [styles.tableCellLast, { flex: column.flex }]
: [
styles.tableCell,
{
flex: column.flex,
color: cellData?.color || 'black',
},
];
return (
<View key={column.key} style={cellStyle}>
<Text>{column.key === 'no' ? label : cellData?.value || ''}</Text>
</View>
);
})}
</View>
);
};
@@ -0,0 +1,89 @@
'use client';
import { Text, View, StyleSheet } from '@react-pdf/renderer';
export interface PdfColumn {
key: string;
header: string;
flex: number;
align?: 'left' | 'center' | 'right';
}
const styles = StyleSheet.create({
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
});
interface PdfTheadProps {
columns: PdfColumn[];
}
export const PdfThead = ({ columns }: PdfTheadProps) => {
return (
<View style={[styles.tableRow, styles.tableHeader]}>
{columns.map((column, index) => {
const align = column.align || 'center';
const isLastColumn = index === columns.length - 1;
const cellStyle =
align === 'right'
? [
styles.tableCellHeaderRight,
{
flex: column.flex,
textAlign: 'right' as const,
borderRightWidth: isLastColumn ? 0 : 1,
},
]
: [
styles.tableCellHeader,
{
flex: column.flex,
textAlign: align as 'left' | 'center' | 'right',
borderRightWidth: isLastColumn ? 0 : 1,
},
];
return (
<View key={column.key} style={cellStyle}>
<Text>{column.header}</Text>
</View>
);
})}
</View>
);
};
+7
View File
@@ -0,0 +1,7 @@
export { PdfTable } from './PdfTable';
export { PdfThead } from './PdfThead';
export { PdfTbody } from './PdfTbody';
export { PdfTfoot } from './PdfTfoot';
export type { PdfColumn } from './PdfThead';
export type { PdfTbodyCell } from './PdfTbody';
export type { PdfTfootCell } from './PdfTfoot';
@@ -0,0 +1,32 @@
import IconSkeleton from '@/components/helper/skeleton/IconSkeleton';
import { Icon } from '@iconify/react';
const DataStateSkeleton = ({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) => {
return (
<div className='flex flex-col items-center justify-center'>
<IconSkeleton
className={{
outer: 'mb-2.25',
}}
>
{icon}
</IconSkeleton>
<h3 className='text-base-content/50 font-semibold text-sm mb-1'>
{title}
</h3>
<p className='text-base-content/50 text-xs text-center max-w-xs'>
{description}
</p>
</div>
);
};
export default DataStateSkeleton;
@@ -0,0 +1,33 @@
import { cn } from '@/lib/helper';
import { ReactNode } from 'react';
const IconSkeleton = ({
children,
className,
}: {
children: ReactNode;
className?: {
outer?: string;
inner?: string;
};
}) => {
return (
<div
className={cn(
'w-12.5 h-12.5 bg-[var(--main-color-base-100,#FFFFFF)] border border-base-content/10 rounded-[0.875rem] shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center',
className?.outer
)}
>
<div
className={cn(
'w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]',
className?.inner
)}
>
{children}
</div>
</div>
);
};
export default IconSkeleton;
+51 -24
View File
@@ -113,7 +113,15 @@ const DateInput = ({
}; };
const handleSelectSingle = (selectedDate?: Date) => { const handleSelectSingle = (selectedDate?: Date) => {
if (!selectedDate) return; if (!selectedDate) {
setSelected(undefined);
setDisplayValue('');
const syntheticEvent = {
target: { name, value: '' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
return;
}
if (minDate && selectedDate < minDate) { if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`); setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return; return;
@@ -136,7 +144,15 @@ const DateInput = ({
}; };
const handleSelectRange = (range?: { from?: Date; to?: Date }) => { const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
if (!range) return; if (!range) {
setSelectedRange({});
setDisplayValue('');
const syntheticEvent = {
target: { name, value: { from: '', to: '' } },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
return;
}
setSelectedRange(range); setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : ''; const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
@@ -188,17 +204,12 @@ const DateInput = ({
const finalErrorMessage = internalError || externalErrorMessage; const finalErrorMessage = internalError || externalErrorMessage;
return ( return (
<div <div className={cn('w-full flex flex-col text-start', className?.wrapper)}>
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && ( {label && (
<label <label
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full py-2 text-xs font-semibold leading-5',
{ 'text-error': finalIsError }, { 'text-error': finalIsError },
className?.label className?.label
)} )}
@@ -215,7 +226,7 @@ const DateInput = ({
<div <div
className={cn( className={cn(
'input h-12 bg-inherit px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border', 'input h-fit bg-inherit px-3 py-2.5 text-base font-normal leading-6 w-full rounded-lg transition-all duration-200 flex items-center border border-base-content/10',
{ {
'border-error': finalIsError, 'border-error': finalIsError,
'border-success': externalValid && !finalIsError, 'border-success': externalValid && !finalIsError,
@@ -234,7 +245,10 @@ const DateInput = ({
disabled={disabled} disabled={disabled}
readOnly // ✅ tidak bisa diketik manual readOnly // ✅ tidak bisa diketik manual
className={cn( className={cn(
'grow bg-transparent cursor-pointer focus:outline-none', 'grow bg-transparent cursor-pointer focus:outline-none text-sm leading-tight',
{
'cursor-not-allowed': readOnly,
},
className?.input className?.input
)} )}
/> />
@@ -245,10 +259,10 @@ const DateInput = ({
</div> </div>
)} )}
<Icon <Icon
icon='uil:calendar' icon='heroicons:calendar-date-range'
width={24} width={15}
height={24} height={15}
className='cursor-pointer text-dark' className='cursor-pointer text-base-content/20'
onClick={(e) => onClick={(e) =>
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>) handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
} }
@@ -256,17 +270,17 @@ const DateInput = ({
</div> </div>
{!finalIsError && bottomLabel && ( {!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)} )}
{finalIsError && finalErrorMessage && ( {finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{finalErrorMessage}</p> <p className='w-full mt-1.5 text-xs text-error'>{finalErrorMessage}</p>
)} )}
<Modal <Modal
ref={calendarModal.ref} ref={calendarModal.ref}
className={{ className={{
modal: 'rounded', modal: 'rounded',
modalBox: `!max-w-max min-h-${isRange ? '124' : '110'} flex flex-col`, modalBox: `max-w-max flex flex-col`,
}} }}
closeOnBackdrop closeOnBackdrop
> >
@@ -282,7 +296,11 @@ const DateInput = ({
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)} endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selectedRange as DateRange} selected={selectedRange as DateRange}
onSelect={handleSelectRange} onSelect={handleSelectRange}
footer={<div className='text-center mt-3'>{displayValue}</div>} footer={
<div className='text-center py-2 text-base-content/65 font-semibold text-xs'>
{displayValue}
</div>
}
disabled={ disabled={
[ [
minDate ? { before: minDate } : undefined, minDate ? { before: minDate } : undefined,
@@ -312,17 +330,26 @@ const DateInput = ({
)} )}
<div className='mt-auto flex flex-col gap-2'> <div className='mt-auto flex flex-col gap-2'>
{isRange && ( {isRange && (
<small className='text-secondary'> <small className='text-base-content/65'>
Tekan dua kali untuk memilih tanggal awal Tekan dua kali untuk reset tanggal awal
</small> </small>
)} )}
<div className='flex h-full justify-end items-end gap-2'> <div className='flex h-full justify-end items-end gap-1.5 mt-3'>
<Button type='button' color='warning' onClick={handleResetDate}> <Button
type='button'
color='none'
className='bg-transparent hover:bg-base-content/10 border-none text-base text-base-content/65 px-3'
onClick={handleResetDate}
>
Reset Reset
</Button> </Button>
{isRange && ( {isRange && (
<Button type='button' onClick={handleSaveDate}> <Button
type='button'
className='rounded-lg px-3 py-2 text-white'
onClick={handleSaveDate}
>
Simpan Simpan
</Button> </Button>
)} )}
+11 -7
View File
@@ -41,7 +41,7 @@ const FileInput = ({
return ( return (
<div <div
className={cn( className={cn(
'w-full flex flex-col gap-2 text-start', 'w-full flex flex-col gap-0 text-start rounded-lg',
className?.wrapper className?.wrapper
)} )}
> >
@@ -49,7 +49,7 @@ const FileInput = ({
<label <label
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full py-2 text-xs font-semibold leading-5',
{ {
'text-error': isError, 'text-error': isError,
}, },
@@ -77,15 +77,19 @@ const FileInput = ({
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
disabled={disabled} disabled={disabled}
className={cn('grow file-input w-full h-12 rounded', className?.input)} className={cn(
'grow file-input w-full h-fit px-3 py-1.5 text-sm font-normal leading-6 rounded-lg! outline-none! transition-all duration-200 bg-white border-base-content/10',
className?.input
)}
readOnly={readOnly} readOnly={readOnly}
/> />
{bottomLabel && ( {!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)} )}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div> </div>
); );
}; };
+395 -111
View File
@@ -9,15 +9,20 @@ import Select, {
SingleValue, SingleValue,
components as ReactSelectComponents, components as ReactSelectComponents,
ControlProps, ControlProps,
MenuListProps,
} from 'react-select'; } from 'react-select';
import CreatableSelect from 'react-select/creatable'; import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated'; import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper'; import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite';
import { httpClientFetcher } from '@/services/http/client'; import { httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import {
import { isResponseSuccess } from '@/lib/api-helper'; BaseApiResponse,
ErrorApiResponse,
SuccessApiResponse,
} from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType { export interface OptionType {
value: string | number; value: string | number;
@@ -35,7 +40,9 @@ interface SelectInputBaseProps<T = OptionType> {
bottomLabel?: ReactNode; bottomLabel?: ReactNode;
options: T[]; options: T[];
optionComponent?: OptionComponent<T>; optionComponent?: OptionComponent<T>;
components?: Partial<typeof ReactSelectComponents>;
isDisabled?: boolean; isDisabled?: boolean;
readOnly?: boolean;
isLoading?: boolean; isLoading?: boolean;
isClearable?: boolean; isClearable?: boolean;
isRtl?: boolean; isRtl?: boolean;
@@ -47,6 +54,9 @@ interface SelectInputBaseProps<T = OptionType> {
wrapper?: string; wrapper?: string;
label?: string; label?: string;
select?: string; select?: string;
inputPrefix?: string;
inputSuffix?: string;
inputPrefixSuffixWrapper?: string;
}; };
isError?: boolean; isError?: boolean;
errorMessage?: string; errorMessage?: string;
@@ -55,10 +65,16 @@ interface SelectInputBaseProps<T = OptionType> {
delay?: number; delay?: number;
onInputChange?: (search: string) => void; onInputChange?: (search: string) => void;
startAdornment?: ReactNode; startAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
menuPortalTarget?: HTMLElement | null; menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
} }
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> { export interface SelectInputProps<T = OptionType>
extends SelectInputBaseProps<T> {
createables?: boolean; createables?: boolean;
value?: T | T[] | null; value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void; onChange?: (val: T | T[] | null) => void;
@@ -73,7 +89,7 @@ const CustomControl = <
>( >(
props: ControlProps<Option, IsMulti, Group> props: ControlProps<Option, IsMulti, Group>
) => { ) => {
const { children } = props; const { children, innerProps } = props;
const customProps = props.selectProps as unknown as { const customProps = props.selectProps as unknown as {
shouldShowAdornment?: boolean; shouldShowAdornment?: boolean;
@@ -85,7 +101,7 @@ const CustomControl = <
return ( return (
<ReactSelectComponents.Control {...props}> <ReactSelectComponents.Control {...props}>
<div className='flex-1 px-4! py-1.5 gap-1 flex items-center'> <div className='flex-1 pl-3 gap-1 flex items-center' {...innerProps}>
{shouldShowAdornment && startAdornment} {shouldShowAdornment && startAdornment}
{children} {children}
</div> </div>
@@ -93,6 +109,29 @@ const CustomControl = <
); );
}; };
const CustomMenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: MenuListProps<Option, IsMulti, Group>
) => {
const { children, selectProps, options } = props;
const { isLoading } = selectProps;
return (
<ReactSelectComponents.MenuList {...props}>
{children}
{options.length > 0 && isLoading && (
<div className='px-3 py-2 rounded-md text-center text-gray-400'>
<span className='loading loading-spinner loading-md' />
</div>
)}
</ReactSelectComponents.MenuList>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => { const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const { const {
label, label,
@@ -101,6 +140,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onChange, onChange,
options, options,
optionComponent, optionComponent,
components: customComponents,
isDisabled, isDisabled,
isLoading, isLoading,
isClearable, isClearable,
@@ -118,7 +158,13 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
createables = false, createables = false,
onInputChange, onInputChange,
startAdornment, startAdornment,
inputPrefix,
inputSuffix,
menuPortalTarget, menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
onMenuScrollToBottom,
readOnly,
} = props; } = props;
const [internalInputValue, setInternalInputValue] = useState(''); const [internalInputValue, setInternalInputValue] = useState('');
@@ -128,14 +174,18 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const components = useMemo(() => { const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {}; const base = isAnimated ? animatedComponents : {};
const customComponents = { ...base, IndicatorSeparator: () => null }; const mergedComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) { if (startAdornment) {
customComponents.Control = CustomControl; mergedComponents.Control = CustomControl;
} }
return customComponents; if (customComponents) {
}, [isAnimated, startAdornment]); Object.assign(mergedComponents, customComponents);
}
return mergedComponents;
}, [isAnimated, startAdornment, customComponents]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'input-change') setInternalInputValue(val);
@@ -163,16 +213,11 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}; };
return ( return (
<div <div className={cn('w-full flex flex-col text-start', className?.wrapper)}>
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && ( {label && (
<span <span
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full py-2 text-xs font-semibold leading-5',
{ 'text-error': isError }, { 'text-error': isError },
className?.label className?.label
)} )}
@@ -189,87 +234,264 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
</span> </span>
)} )}
<SelectComponent<T, boolean, GroupBase<T>> {inputPrefix || inputSuffix ? (
instanceId='select' <div
value={value ?? (isMulti ? [] : null)} className={cn(
onChange={onChange ? handleChange : undefined} 'relative flex text-sm',
options={options} className?.inputPrefixSuffixWrapper
menuIsOpen={openMenu} )}
inputValue={internalInputValue} >
onInputChange={internalInputChangeHandler} {inputPrefix && (
onMenuClose={() => setInternalInputValue('')} <div
isMulti={isMulti} className={cn(
isDisabled={isDisabled} 'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
isLoading={isLoading} {
isClearable={isClearable} 'bg-gray-100 border-base-content/10': !isDisabled,
isRtl={isRtl} 'bg-gray-50 border-base-content/10': isDisabled,
isSearchable={isSearchable} 'border-error': isError,
placeholder={placeholder} },
className={cn('w-full', className?.select)} className?.inputPrefix
classNames={{ )}
...(!startAdornment && { >
{inputPrefix}
</div>
)}
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled || readOnly}
isLoading={isLoading}
isClearable={isClearable}
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full flex-1', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn('w-full border bg-white transition-shadow', 'rounded-lg!', {
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200':
isFocused && !startAdornment,
'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
'rounded-l-none!': inputPrefix && !startAdornment,
'rounded-r-none!': inputSuffix && !startAdornment,
}),
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
placeholder: () =>
cn({
'text-gray-400 text-sm leading-tight': !isError,
'text-red-300!': isError,
}),
singleValue: () =>
cn({
'm-0! text-gray-900 text-sm leading-tight': !isError,
'text-error!': isError,
'text-gray-900!': readOnly,
}),
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
indicatorsContainer: () =>
cn('flex items-center gap-1 pr-3 py-2'),
dropdownIndicator: ({ isFocused }) =>
cn('p-0! rounded hover:bg-gray-100', {
'text-gray-900': isFocused,
'text-gray-500': !isFocused,
'text-error!': isError,
}),
clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
menu: () =>
cn(
'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
),
menuList: () => cn('p-0! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
cn('px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused,
'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected,
}),
multiValue: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn(
'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
selectedValues[index]?.className
);
},
multiValueRemove: () => cn('p-0! w-3 h-3'),
multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue() as T[];
return cn(
'p-0! text-base-content! text-xs!',
selectedValues[index]?.labelClassName
);
},
}}
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
MenuList: CustomMenuList,
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined'
? (menuPortalTarget ?? document.body)
: undefined
}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
multiValue(base) {
return {
...base,
borderRadius: '8px',
};
},
}}
onMenuScrollToBottom={onMenuScrollToBottom}
/>
{inputSuffix && (
<div
className={cn(
'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
{
'bg-gray-100 border-base-content/10': !isDisabled,
'bg-gray-50 border-base-content/10': isDisabled,
'border-error': isError,
},
className?.inputSuffix
)}
>
{inputSuffix}
</div>
)}
</div>
) : (
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled || readOnly}
isLoading={isLoading}
isClearable={isClearable}
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) => control: ({ isFocused, isDisabled }) =>
cn( cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!', 'w-full border bg-white transition-shadow',
// Gunakan rounded-lg untuk semua kasus
'rounded-lg!',
{ {
'cursor-pointer!': !readOnly && !isDisabled,
'border-red-500! ring-2 ring-red-200': isError, 'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused, 'border-indigo-500 ring-2 ring-indigo-200':
'border-gray-300': !isError && !isFocused, isFocused && !startAdornment,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled, 'border-base-content/10!': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed':
isDisabled && !readOnly,
'bg-transparent! cursor-not-allowed!': readOnly,
} }
), ),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'), valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
}), placeholder: () =>
placeholder: () => cn({
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }), 'text-gray-400 text-sm leading-tight': !isError,
singleValue: () => 'text-red-300!': isError,
cn({ 'text-gray-900': !isError, 'text-error!': isError }), }),
input: () => cn('text-gray-900'), singleValue: () =>
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'), cn({
dropdownIndicator: ({ isFocused }) => 'm-0! text-gray-900 text-sm leading-tight': !isError,
cn('p-1 rounded hover:bg-gray-100', { 'text-error!': isError,
'text-gray-900': isFocused, 'text-gray-900!': readOnly,
'text-gray-500': !isFocused, }),
'text-error!': isError, input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
}), indicatorsContainer: () => cn('flex items-center gap-1 pr-3 py-2'),
menu: () => dropdownIndicator: ({ isFocused }) =>
cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'), cn('p-0! rounded hover:bg-gray-100', {
menuList: () => cn('p-2! max-h-60 overflow-auto'), 'text-gray-900': isFocused,
option: ({ isFocused, isSelected }) => 'text-gray-500': !isFocused,
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', { 'text-error!': isError,
'bg-indigo-600 text-white': isFocused, }),
'bg-blue-500!': isSelected, clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
'text-gray-700': !isFocused && !isSelected, menu: () =>
}), cn(
multiValue: ({ getValue, index }) => { 'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
const selectedValues = getValue() as T[]; ),
return cn( menuList: () => cn('p-0! max-h-60 overflow-auto'),
'bg-indigo-50 rounded py-0.5 pl-2 pr-1 flex items-center gap-1!', option: ({ isFocused, isSelected }) =>
selectedValues[index]?.className cn('px-3 py-2 rounded-md cursor-pointer!', {
); 'bg-indigo-600 text-white': isFocused,
}, 'bg-blue-500!': isSelected,
multiValueLabel: ({ getValue, index }) => { 'text-gray-700': !isFocused && !isSelected,
const selectedValues = getValue() as T[]; }),
return cn('text-indigo-700', selectedValues[index]?.labelClassName); multiValue: ({ getValue, index }) => {
}, const selectedValues = getValue() as T[];
}} return cn(
components={{ 'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
...components, selectedValues[index]?.className
...(optionComponent ? { Option: optionComponent } : {}), );
}} },
{...(startAdornment && { multiValueRemove: () => cn('p-0! w-3 h-3'),
shouldShowAdornment, multiValueLabel: ({ getValue, index }) => {
startAdornment, const selectedValues = getValue() as T[];
})} return cn(
menuPortalTarget={ 'p-0! text-base-content! text-xs!',
typeof document !== 'undefined' selectedValues[index]?.labelClassName
? (menuPortalTarget ?? document.body) );
: undefined },
} }}
styles={{ components={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), ...components,
}} ...(optionComponent ? { Option: optionComponent } : {}),
/> MenuList: CustomMenuList,
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined'
? (menuPortalTarget ?? document.body)
: undefined
}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
multiValue(base) {
return {
...base,
borderRadius: '8px',
};
},
}}
onMenuScrollToBottom={onMenuScrollToBottom}
/>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>} {isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
@@ -280,7 +502,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}; };
const useSelect = <T,>( const useSelect = <T,>(
basePath: string, basePath: string | null,
valueKey: keyof T | string, valueKey: keyof T | string,
labelKey: keyof T | string, labelKey: keyof T | string,
searchKey: string = 'search', searchKey: string = 'search',
@@ -288,34 +510,96 @@ const useSelect = <T,>(
) => { ) => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const optionsUrlParams = useMemo(() => { const pageKey = 'page';
return new URLSearchParams({ const limitKey = 'limit';
const limit = params?.['limit'] ?? 10;
const getKey = (
pageIndex: number,
previousPageData?: BaseApiResponse<T[]>
) => {
// stop when backend says no more pages
if (previousPageData && isResponseSuccess(previousPageData)) {
const meta = previousPageData.meta;
if (meta && meta.page >= meta.total_pages) return null;
}
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey]: inputValue ?? '', [searchKey]: inputValue ?? '',
...params, [pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString(); }).toString();
}, [inputValue, searchKey, params]);
const optionsUrl = `${basePath}?${optionsUrlParams}`; return basePath ? `${basePath}?${qs}` : null;
};
const { data, isLoading } = useSWR(optionsUrl, async (url) => { const {
return await httpClientFetcher<BaseApiResponse<T[]>>(url); data: pages,
}); isLoading,
isValidating,
size,
setSize,
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
httpClientFetcher<BaseApiResponse<T[]>>(url)
);
const options = isResponseSuccess(data) const options = useMemo(() => {
? data.data.map((item) => { if (!pages) return [];
return {
value: getByPath<T, number>(item, valueKey as string), return pages.flatMap((page) =>
label: getByPath<T, string>(item, labelKey as string), isResponseSuccess(page)
}; ? page.data.map((item) => ({
}) value: getByPath<T, number>(item, valueKey as string),
: []; label: getByPath<T, string>(item, labelKey as string),
}))
: []
);
}, [pages, valueKey, labelKey]);
const lastPage = pages?.[pages.length - 1];
const hasMore =
!!lastPage &&
isResponseSuccess(lastPage) &&
!!lastPage.meta &&
lastPage.meta.page < lastPage.meta.total_pages;
const loadMore = () => {
if (!hasMore) return;
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 : [])) ??
[],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
return { return {
inputValue, inputValue,
setInputValue, setInputValue,
options, options,
isLoadingOptions: isLoading, rawData: isResponseSuccess(pages?.[latestPagesIndex])
rawData: data, ? formattedSuccessRawData
: formattedErrorRawData,
isLoadingOptions: isLoading || isValidating,
isLoadingMore: isValidating && size > 1,
hasMore,
loadMore,
}; };
}; };
@@ -0,0 +1,89 @@
'use client';
import { useMemo } from 'react';
import {
OptionProps,
GroupBase,
components as ReactSelectComponents,
} from 'react-select';
import SelectInput, { OptionType, SelectInputProps } from './SelectInput';
import { cn } from '@/lib/helper';
interface SelectInputCheckboxProps<T = OptionType>
extends Omit<
SelectInputProps<T>,
'closeMenuOnSelect' | 'hideSelectedOptions' | 'optionComponent'
> {
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
}
const CheckboxOption = <
T extends OptionType,
IsMulti extends boolean,
Group extends GroupBase<T>,
>(
props: OptionProps<T, IsMulti, Group>
) => {
const { isSelected, label, innerRef, innerProps, className, isFocused } =
props;
return (
<div
ref={innerRef}
{...innerProps}
className={cn(
'flex items-center gap-3 p-3 cursor-pointer transition-all hover:bg-primary/5',
{
'bg-primary/5': isFocused,
},
className
)}
>
<input
type='checkbox'
checked={isSelected}
onChange={() => null}
className='checkbox checkbox-sm rounded-md checkbox-primary pointer-events-none border-base-content/10'
/>
<label className='cursor-pointer flex-1 select-none text-sm text-base-content/50 font-medium'>
{label}
</label>
</div>
);
};
const SelectInputCheckbox = <T extends OptionType>(
props: SelectInputCheckboxProps<T>
) => {
const {
closeMenuOnSelect = false,
hideSelectedOptions = false,
isMulti = true,
className,
...restProps
} = props;
const customComponents = useMemo(() => {
return {
Option: CheckboxOption as typeof ReactSelectComponents.Option,
};
}, []);
return (
<SelectInput<T>
{...restProps}
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={{
...className,
select: cn(className?.select, 'select-checkbox'),
}}
components={customComponents}
/>
);
};
export default SelectInputCheckbox;
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useMemo } from 'react';
import {
OptionProps,
GroupBase,
components as ReactSelectComponents,
} from 'react-select';
import SelectInput, { OptionType, SelectInputProps } from './SelectInput';
import { cn } from '@/lib/helper';
interface SelectInputRadioProps<T = OptionType>
extends Omit<SelectInputProps<T>, 'closeMenuOnSelect' | 'optionComponent'> {
closeMenuOnSelect?: boolean;
}
const RadioOption = <
T extends OptionType,
IsMulti extends boolean,
Group extends GroupBase<T>,
>(
props: OptionProps<T, IsMulti, Group>
) => {
const { isSelected, label, innerRef, innerProps, className, isFocused } =
props;
return (
<div
ref={innerRef}
{...innerProps}
className={cn(
'flex items-center gap-3 p-3 cursor-pointer transition-all hover:bg-primary/5',
{
'bg-primary/5': isFocused,
},
className
)}
>
<input
type='radio'
checked={isSelected}
onChange={() => null}
className='radio radio-md radio-primary pointer-events-none'
/>
<label className='cursor-pointer flex-1 select-none text-sm text-base-content/50 font-medium'>
{label}
</label>
</div>
);
};
const SelectInputRadio = <T extends OptionType>(
props: SelectInputRadioProps<T>
) => {
const { closeMenuOnSelect = true, className, ...restProps } = props;
const customComponents = useMemo(() => {
return {
Option: RadioOption as typeof ReactSelectComponents.Option,
};
}, []);
return (
<SelectInput<T>
{...restProps}
closeMenuOnSelect={closeMenuOnSelect}
className={{
...className,
select: cn(className?.select, 'select-radio'),
}}
components={customComponents}
/>
);
};
export default SelectInputRadio;
+7 -5
View File
@@ -53,7 +53,7 @@ const TextArea = ({
return ( return (
<div <div
className={cn( className={cn(
'w-full flex flex-col gap-2 text-start', 'w-full flex flex-col gap-0 text-start',
className?.wrapper className?.wrapper
)} )}
> >
@@ -61,7 +61,7 @@ const TextArea = ({
<label <label
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full py-2 text-xs font-semibold leading-5',
{ {
'text-error': isError, 'text-error': isError,
}, },
@@ -83,7 +83,7 @@ const TextArea = ({
<textarea <textarea
className={cn( className={cn(
'textarea h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white', 'textarea h-auto px-3 py-2.5 text-sm text-base-content font-normal leading-6 w-full rounded-lg outline-none! transition-all bg-white border-base-content/10',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -110,9 +110,11 @@ const TextArea = ({
)} )}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
{isError && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)} )}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div> </div>
); );
}; };
+29 -15
View File
@@ -21,6 +21,9 @@ export interface TextInputProps {
label?: string; label?: string;
inputWrapper?: string; inputWrapper?: string;
input?: string; input?: string;
inputPrefix?: string;
inputSuffix?: string;
inputPrefixSuffixWrapper?: string;
}; };
isError?: boolean; isError?: boolean;
isValid?: boolean; isValid?: boolean;
@@ -62,7 +65,7 @@ const TextInput = ({
return ( return (
<div <div
className={cn( className={cn(
'w-full flex flex-col gap-2 text-start', 'w-full flex flex-col gap-0 text-start rounded-lg',
className?.wrapper className?.wrapper
)} )}
> >
@@ -70,7 +73,7 @@ const TextInput = ({
<label <label
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full py-2 text-xs font-semibold leading-5',
{ {
'text-error': isError, 'text-error': isError,
}, },
@@ -90,15 +93,23 @@ const TextInput = ({
)} )}
{inputPrefix || inputSuffix ? ( {inputPrefix || inputSuffix ? (
<div className='relative flex'> <div
className={cn(
'relative flex text-sm',
className?.inputPrefixSuffixWrapper
)}
>
{inputPrefix && ( {inputPrefix && (
<div <div
className={cn( className={cn(
'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200', 'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
{ {
'bg-gray-100 border-gray-300': !disabled, 'bg-gray-100 border-base-content/10': !disabled,
'bg-gray-50 border-gray-200': disabled, 'bg-gray-50 border-base-content/10': disabled,
} 'border-error': isError,
'border-success!': isValid,
},
className?.inputPrefix
)} )}
> >
{inputPrefix} {inputPrefix}
@@ -107,7 +118,7 @@ const TextInput = ({
<div <div
className={cn( className={cn(
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white', 'input h-fit px-3 py-2.5 gap-1.5 text-sm font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white border-base-content/10',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -154,11 +165,14 @@ const TextInput = ({
{inputSuffix && ( {inputSuffix && (
<div <div
className={cn( className={cn(
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200', 'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
{ {
'bg-gray-100 border-gray-300': !disabled, 'bg-gray-100 border-base-content/10': !disabled,
'bg-gray-50 border-gray-200': disabled, 'bg-gray-50 border-base-content/10': disabled,
} 'border-error': isError,
'border-success!': isValid,
},
className?.inputSuffix
)} )}
> >
{inputSuffix} {inputSuffix}
@@ -168,7 +182,7 @@ const TextInput = ({
) : ( ) : (
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white', 'input h-fit px-3 py-2.5 gap-1.5 text-sm font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white border-base-content/10',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -202,10 +216,10 @@ const TextInput = ({
)} )}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)} )}
{isError && errorMessage && ( {isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p> <p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)} )}
</div> </div>
); );
+99 -77
View File
@@ -35,29 +35,29 @@ const iconConfig = {
info: { info: {
icon: 'material-symbols:info-outline-rounded', icon: 'material-symbols:info-outline-rounded',
iconClassName: 'text-info-content', iconClassName: 'text-info-content',
bgClassName: 'bg-info', innerRingClassName: 'bg-info',
outerRingClassName: 'bg-info/20', middleRingClassName: 'bg-info/12',
borderClassName: 'border-info', outerRingClassName: 'border-info/12 bg-info/8',
}, },
success: { success: {
icon: 'heroicons:check', icon: 'heroicons:check',
iconClassName: 'text-white', iconClassName: 'text-white',
bgClassName: 'bg-[#00D390]', innerRingClassName: 'bg-success',
outerRingClassName: 'bg-[#00D3901F]', middleRingClassName: 'bg-success/12',
borderClassName: 'border-[#CCF7EB]', outerRingClassName: 'border-success/12 bg-success/8',
}, },
error: { error: {
icon: 'solar:danger-triangle-linear', icon: 'heroicons:exclamation-triangle',
iconClassName: 'text-error-content', iconClassName: 'text-error-content',
bgClassName: 'bg-[#f03338]', innerRingClassName: 'bg-error',
outerRingClassName: 'bg-[#f3cdcd]', middleRingClassName: 'bg-error/12',
borderClassName: 'border-[#fff0ef]', outerRingClassName: 'border-error/12 bg-error/8',
}, },
} as const; } as const;
const ConfirmationModalIcon = ({ const ConfirmationModalIcon = ({
type, type,
size = 24, size = 16,
}: { }: {
type: 'info' | 'success' | 'error'; type: 'info' | 'success' | 'error';
size?: number; size?: number;
@@ -65,28 +65,22 @@ const ConfirmationModalIcon = ({
const config = iconConfig[type]; const config = iconConfig[type];
return ( return (
<div className='flex items-center justify-center p-2'> <div
<div className={cn('rounded-full border p-[5px]', config.outerRingClassName)}
className={cn( >
'rounded-full border-4 p-1', <div className={cn('rounded-full p-2', config.middleRingClassName)}>
config.outerRingClassName, <div
config.borderClassName className={cn(
)} 'rounded-full p-1 flex items-center justify-center',
> config.innerRingClassName
<div className={cn('rounded-full p-1', config.outerRingClassName)}> )}
<div >
className={cn( <Icon
'rounded-full p-3 flex items-center justify-center', icon={config.icon}
config.bgClassName width={size}
)} height={size}
> className={config.iconClassName}
<Icon />
icon={config.icon}
width={size}
height={size}
className={config.iconClassName}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +97,7 @@ const ConfirmationModal = ({
secondaryButton, secondaryButton,
className, className,
children, children,
iconSize = 32, iconSize = 16,
iconPosition = 'center', iconPosition = 'center',
}: ConfirmationModalProps) => { }: ConfirmationModalProps) => {
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false); const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
@@ -123,7 +117,14 @@ const ConfirmationModal = ({
}; };
return ( return (
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}> <Modal
ref={ref}
closeOnBackdrop={closeOnBackdrop}
className={{
...className,
modalBox: cn('rounded-xl p-4', className?.modalBox),
}}
>
<div className='w-full flex flex-col gap-4'> <div className='w-full flex flex-col gap-4'>
{iconPosition === 'center' ? ( {iconPosition === 'center' ? (
<> <>
@@ -143,7 +144,7 @@ const ConfirmationModal = ({
</> </>
) : ( ) : (
<div <div
className={cn('flex flex-row items-center gap-4', { className={cn('flex flex-row items-center gap-3', {
'flex-row': iconPosition === 'left', 'flex-row': iconPosition === 'left',
'flex-row-reverse': iconPosition === 'right', 'flex-row-reverse': iconPosition === 'right',
})} })}
@@ -153,12 +154,12 @@ const ConfirmationModal = ({
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<p className='font-medium'> <p className='text-sm font-semibold'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'} {text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p> </p>
{subtitleText && ( {subtitleText && (
<p className='text-sm text-gray-400'>{subtitleText}</p> <p className='text-xs text-base-content/50'>{subtitleText}</p>
)} )}
</div> </div>
</div> </div>
@@ -166,46 +167,67 @@ const ConfirmationModal = ({
{children && <div className='w-full'>{children}</div>} {children && <div className='w-full'>{children}</div>}
<div className='w-full flex flex-row gap-2'> {(secondaryButton || primaryButton) && (
{secondaryButton && secondaryButton.text && ( <div
<Button className={cn('w-full grid gap-3', {
{...secondaryButton} 'grid-cols-2': secondaryButton && primaryButton,
variant='outline' 'grid-cols-1':
color={secondaryButton?.color} (secondaryButton && !primaryButton) ||
isLoading={secondaryButton?.isLoading} (!secondaryButton && primaryButton),
disabled={ })}
secondaryButton?.isLoading !== undefined >
? secondaryButton?.isLoading {secondaryButton && secondaryButton.text && (
: isPrimaryButtonLoading <Button
} {...secondaryButton}
onClick={closeModalHandler} variant='outline'
className='grow' color={secondaryButton?.color}
> isLoading={secondaryButton?.isLoading}
{secondaryButton?.text ?? 'Tidak'} disabled={
</Button> secondaryButton?.isLoading !== undefined
)} ? secondaryButton?.isLoading
: isPrimaryButtonLoading
}
onClick={(e) => {
if (secondaryButton?.onClick) {
secondaryButton.onClick(e);
} else {
closeModalHandler();
}
}}
className={cn(
'p-2 rounded-xl text-sm',
secondaryButton?.className
)}
>
{secondaryButton?.text ?? 'Tidak'}
</Button>
)}
{primaryButton && primaryButton.text && ( {primaryButton && primaryButton.text && (
<Button <Button
{...primaryButton} {...primaryButton}
color={primaryButton?.color ?? 'info'} color={primaryButton?.color ?? 'info'}
onClick={primaryButtonClickHandler} onClick={primaryButtonClickHandler}
isLoading={ isLoading={
primaryButton?.isLoading !== undefined primaryButton?.isLoading !== undefined
? primaryButton?.isLoading ? primaryButton?.isLoading
: isPrimaryButtonLoading : isPrimaryButtonLoading
} }
disabled={ disabled={
primaryButton?.isLoading !== undefined primaryButton?.isLoading !== undefined
? primaryButton?.isLoading ? primaryButton?.isLoading
: isPrimaryButtonLoading : isPrimaryButtonLoading
} }
className='grow' className={cn(
> 'p-2 rounded-xl text-sm',
{primaryButton?.text ?? 'Ya'} primaryButton?.className
</Button> )}
)} >
</div> {primaryButton?.text ?? 'Ya'}
</Button>
)}
</div>
)}
</div> </div>
</Modal> </Modal>
); );
@@ -32,6 +32,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
className, className,
rows = 3, rows = 3,
placeholder = 'Catatan...', placeholder = 'Catatan...',
...props
}) => { }) => {
const randomId = useId(); const randomId = useId();
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
@@ -55,6 +56,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
}} }}
secondaryButton={secondaryButton} secondaryButton={secondaryButton}
className={className} className={className}
{...props}
> >
<TextArea <TextArea
name={randomId} name={randomId}
+9 -9
View File
@@ -39,16 +39,15 @@ const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
<li> <li>
<Link <Link
href={item.link} href={item.link}
className={cn( className={cn('px-3 py-1.5', {
{ 'text-base-content/60': !isItemActive,
'menu-active border-2 border-solid border-base-300': isItemActive, 'menu-active border-[1.5px] border-solid border-base-300':
}, isItemActive,
'px-3 py-1.5' })}
)}
> >
{item.icon && <Icon icon={item.icon} width={20} height={20} />} {item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span> <span className='text-sm'>{item.text}</span>
</Link> </Link>
</li> </li>
); );
@@ -62,12 +61,13 @@ const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
<details open={isItemActive}> <details open={isItemActive}>
<summary <summary
className={cn({ className={cn({
'text-base-content/60': !isItemActive,
'text-primary': isItemActive, 'text-primary': isItemActive,
})} })}
> >
{item.icon && <Icon icon={item.icon} width={20} height={20} />} {item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span> <span className='text-sm'>{item.text}</span>
</summary> </summary>
<ul> <ul>
@@ -88,7 +88,7 @@ const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => { const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => {
return ( return (
<Menu> <Menu className='p-3'>
{menu.map((menuItem, menuIdx) => { {menu.map((menuItem, menuIdx) => {
return ( return (
<SidebarMenuItem <SidebarMenuItem
@@ -3,224 +3,82 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatTitleCase } from '@/lib/helper'; import { formatCurrency, formatTitleCase } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { import { HppItem, ProfitLossItem } from '@/types/api/closing';
DataSummarySubTotal, import { useSearchParams } from 'next/navigation';
HppPurchaseData, import { useMemo } from 'react';
ProfitLossDataAmount,
} from '@/types/api/closing';
import useSWR from 'swr'; import useSWR from 'swr';
type HppTableRow =
| (HppPurchaseData & {
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
budgeting?: never;
realization?: never;
}
| {
type: string;
group_name: string;
group_index: number;
isGroupHeader: false;
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
};
type ProfitLossTableRow =
| (DataSummarySubTotal & {
type: string;
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
rp_per_bird?: never;
rp_per_kg?: never;
amount?: never;
};
const ClosingFinanceTable = ({ const ClosingFinanceTable = ({
projectFlockId, projectFlockId,
}: { }: {
projectFlockId: number; projectFlockId: number;
}) => { }) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR( const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`, `/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() => ClosingApi.getFinance(projectFlockId) () =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
); );
const staticHppRows: Array<{ const hppTableData: HppItem[] = useMemo(() => {
group_name: string; if (isResponseSuccess(finance)) {
type: string; const customItems = {
group_index: number; label: 'HPP dan Pengeluaran',
}> = [ code: 'custom_row',
{ } as HppItem;
group_name: 'HPP dan Pengeluaran', const purchases = finance.data.hpp.items.filter(
type: 'Pembelian PAKAN', (item) => item.category === 'purchase'
group_index: 0, );
}, const totalBudgeting = {
{ label: 'HPP dan Bahan Baku',
group_name: 'HPP dan Pengeluaran', code: 'custom_row',
type: 'Pembelian STARTER', } as HppItem;
group_index: 0, const overheads = finance.data.hpp.items.filter(
}, (item) => item.category === 'overhead'
{ );
group_name: 'HPP dan Pengeluaran', return [customItems, ...purchases, totalBudgeting, ...overheads];
type: 'Pembelian DOC', }
group_index: 0, return [];
}, }, [finance]);
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian PULLET',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian LAYER',
group_index: 0,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Pengeluaran Overhead',
group_index: 1,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Beban Ekspedisi',
group_index: 1,
},
];
const hppTableData: HppTableRow[] = [ const profitLossTableData: ProfitLossItem[] = useMemo(() => {
{ if (isResponseSuccess(finance)) {
group_name: 'HPP dan Pengeluaran', const incomes = finance.data.profit_loss.items.filter(
group_index: 0, (item) => item.type === 'income'
isGroupHeader: true as const, );
}, const purchases = finance.data.profit_loss.items.filter(
...staticHppRows (item) => item.type === 'purchase'
.filter((row) => row.group_index === 0) );
.map((staticRow) => { const overheads = finance.data.profit_loss.items.filter(
const apiData = isResponseSuccess(finance) (item) => item.type === 'overhead'
? finance.data.hpp_purchases.hpp );
.find((g) => g.group_name === staticRow.group_name) const grossProfit = {
?.data.find((d) => d.type === staticRow.type) label: 'LABA RUGI BRUTO',
: null; code: 'custom_row',
type: 'gross_profit',
return { rp_per_bird:
group_name: staticRow.group_name, finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0,
group_index: staticRow.group_index, rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0,
type: staticRow.type, amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0,
budgeting: apiData?.budgeting || { } as ProfitLossItem;
rp_per_bird: 0, const subtotal = {
rp_per_kg: 0, label: 'Subtotal',
amount: 0, code: 'custom_row',
}, type: 'subtotal',
realization: apiData?.realization || { rp_per_bird:
rp_per_bird: 0, finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0,
rp_per_kg: 0, rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0,
amount: 0, amount: finance.data.profit_loss.summary.sub_total.amount ?? 0,
}, } as ProfitLossItem;
isGroupHeader: false as const, return [...incomes, ...purchases, grossProfit, ...overheads, subtotal];
}; }
}), return [];
{ }, [finance]);
group_name: 'HPP dan Bahan Baku',
group_index: 1,
isGroupHeader: true as const,
},
...staticHppRows
.filter((row) => row.group_index === 1)
.map((staticRow) => {
const apiData = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp
.find((g) => g.group_name === staticRow.group_name)
?.data.find((d) => d.type === staticRow.type)
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const,
};
}),
{
group_name: 'HPP',
group_index: 2,
isGroupHeader: true as const,
},
];
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
? [
// Pembelian group
...finance.data.profit_loss.data.pembelian.map((item) => ({
label: 'Pembelian',
group_name: 'Pembelian',
group_index: 1,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.gross_profit.label,
group_name: 'Penjualan',
group_index: 0,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.gross_profit.label,
rp_per_bird:
finance.data.profit_loss.data.summary.gross_profit.rp_per_bird,
rp_per_kg:
finance.data.profit_loss.data.summary.gross_profit.rp_per_kg,
amount: finance.data.profit_loss.data.summary.gross_profit.amount,
},
// Penjualan group
...finance.data.profit_loss.data.penjualan.map((item) => ({
label: 'Penjualan',
group_name: 'Penjualan',
group_index: 0,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.sub_total.label,
group_name: 'Pembelian',
group_index: 1,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.sub_total.label,
rp_per_bird:
finance.data.profit_loss.data.summary.sub_total.rp_per_bird,
rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg,
amount: finance.data.profit_loss.data.summary.sub_total.amount,
},
]
: [];
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
@@ -233,35 +91,21 @@ const ClosingFinanceTable = ({
> >
<div className='grid grid-cols-2 gap-6'> <div className='grid grid-cols-2 gap-6'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<div> <div>Laba Rugi Brutto</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.gross_profit
.label || '-'
)
: 'Laba Rugi Brutto'}
</div>
<div className='text-lg font-bold'> <div className='text-lg font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.gross_profit.amount finance.data.profit_loss.summary.gross_profit.amount
) )
: '-'} : '-'}
</div> </div>
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<div> <div>Laba Rugi Netto</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit.label ||
'-'
)
: 'Laba Rugi Netto'}
</div>
<div className='text-lg font-bold'> <div className='text-lg font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit.amount finance.data.profit_loss.summary.net_profit.amount
) )
: '-'} : '-'}
</div> </div>
@@ -269,11 +113,7 @@ const ClosingFinanceTable = ({
</div> </div>
</Card> </Card>
<Card <Card
title={ title='HPP Purchases'
isResponseSuccess(finance)
? finance.data.hpp_purchases.title
: 'HPP Purchases'
}
variant='bordered' variant='bordered'
collapsible collapsible
className={{ className={{
@@ -281,17 +121,18 @@ const ClosingFinanceTable = ({
}} }}
> >
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<HppTableRow> <Table<HppItem>
data={hppTableData} data={hppTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'No.', header: 'No.',
enableSorting: false, enableSorting: false,
accessorFn: (item, index) => { accessorFn: (item, index) => {
if (item.isGroupHeader) return '-'; if (item.code === 'custom_row') return '-';
const dataRowsBefore = hppTableData const dataRowsBefore = hppTableData
.slice(0, index) .slice(0, index)
.filter((row) => !row.isGroupHeader).length; .filter((row) => row.code !== 'custom_row').length;
return dataRowsBefore + 1; return dataRowsBefore + 1;
}, },
footer: (props) => { footer: (props) => {
@@ -299,9 +140,9 @@ const ClosingFinanceTable = ({
}, },
}, },
{ {
header: 'Type', header: 'Jenis',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'), accessorFn: (item) => formatTitleCase(item.label || '-'),
}, },
{ {
header: 'Budgeting', header: 'Budgeting',
@@ -317,7 +158,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_bird' && return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting
?.rp_per_bird || 0 ?.rp_per_bird || 0
) )
: '-'; : '-';
@@ -333,8 +174,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_kg' && return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting?.rp_per_kg ||
?.rp_per_kg || 0 0
) )
: '-'; : '-';
}, },
@@ -349,8 +190,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_amount' && return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting?.amount || 0
?.amount || 0
) )
: '-'; : '-';
}, },
@@ -371,8 +211,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_bird' && return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization
?.realization?.rp_per_bird || 0 ?.rp_per_bird || 0
) )
: '-'; : '-';
}, },
@@ -387,8 +227,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_kg' && return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization
?.realization?.rp_per_kg || 0 ?.rp_per_kg || 0
) )
: '-'; : '-';
}, },
@@ -403,8 +243,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_amount' && return props.column.id === 'realization_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization?.amount || 0
?.realization?.amount || 0
) )
: '-'; : '-';
}, },
@@ -414,7 +253,7 @@ const ClosingFinanceTable = ({
]} ]}
renderCustomRow={(row) => { renderCustomRow={(row) => {
const rowData = row.original; const rowData = row.original;
if (rowData.isGroupHeader) { if (rowData.code === 'custom_row') {
return ( return (
<tr <tr
key={row.id} key={row.id}
@@ -428,7 +267,7 @@ const ClosingFinanceTable = ({
className={TABLE_DEFAULT_STYLING.bodyColumnClassName} className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
> >
<div className='font-bold'> <div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')} {formatTitleCase(rowData.label ?? '-')}
</div> </div>
</td> </td>
</tr> </tr>
@@ -441,11 +280,7 @@ const ClosingFinanceTable = ({
</div> </div>
</Card> </Card>
<Card <Card
title={ title='Profit/Loss'
isResponseSuccess(finance)
? finance.data.profit_loss.title
: 'Profit/Loss'
}
variant='bordered' variant='bordered'
collapsible collapsible
className={{ className={{
@@ -453,38 +288,32 @@ const ClosingFinanceTable = ({
}} }}
> >
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow> <Table<ProfitLossItem>
data={profitLossTableData} data={profitLossTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'Jenis', header: 'Jenis',
enableSorting: false, enableSorting: false,
accessorFn: (item) => item.type, accessorFn: (item) => item.label,
cell: (item) => ( cell: (item) => (
<div className=''> <div className=''>
{formatTitleCase(item.row.original.type || '-')} {formatTitleCase(item.row.original.label || '-')}
</div> </div>
), ),
footer: (item) => ( footer: () => (
<div className='font-bold uppercase'> <div className='font-bold uppercase'>LABA RUGI NETTO</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit
.label || '-'
)
: '-'}
</div>
), ),
}, },
{ {
header: 'Rp/Ekor', header: 'Rp/Ekor',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0), accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.rp_per_bird || 0 .rp_per_bird || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -495,11 +324,11 @@ const ClosingFinanceTable = ({
header: 'Rp/Kg', header: 'Rp/Kg',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0), accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.rp_per_kg || 0 .rp_per_kg || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -510,11 +339,11 @@ const ClosingFinanceTable = ({
header: 'Jumlah (Rp)', header: 'Jumlah (Rp)',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.amount || 0), accessorFn: (item) => formatCurrency(item.amount || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.amount || 0 .amount || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -524,55 +353,30 @@ const ClosingFinanceTable = ({
]} ]}
renderCustomRow={(row) => { renderCustomRow={(row) => {
const rowData = row.original; const rowData = row.original;
if (rowData.isGroupHeader) { if (rowData.code === 'custom_row') {
if (rowData.amount) {
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.footerRowClassName}
>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold ps-6 uppercase'>
{formatTitleCase(rowData.label ?? '-')}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_bird ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_kg ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.amount ?? 0)}
</div>
</td>
</tr>
);
}
return ( return (
<tr <tr
key={row.id} key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName} className={TABLE_DEFAULT_STYLING.footerRowClassName}
> >
<td <td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
colSpan={4} <div className='font-bold ps-6 uppercase'>
className={TABLE_DEFAULT_STYLING.bodyColumnClassName} {formatTitleCase(rowData.label ?? '-')}
> </div>
</td>
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
<div className='font-bold'> <div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')} {formatCurrency(rowData.rp_per_bird ?? 0)}
</div>
</td>
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_kg ?? 0)}
</div>
</td>
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
<div className='font-bold'>
{formatCurrency(rowData.amount ?? 0)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronakSummary } from '@/types/api/closing';
interface ClosingIncomingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingIncomingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: incomingSapronakSummaries,
isLoading: isLoadingIncomingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries.data.length > 0
: false
);
}
}, [incomingSapronakSummaries, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Ringkasan Sapronak Masuk</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<Table<ClosingIncomingSapronakSummary>
data={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronakSummaries) &&
incomingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksSummaryTable;
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table'; import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingIncomingSapronaksTableProps {
const ClosingIncomingSapronaksTable = ({ const ClosingIncomingSapronaksTable = ({
projectFlockId, projectFlockId,
}: ClosingIncomingSapronaksTableProps) => { }: ClosingIncomingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -43,7 +47,7 @@ const ClosingIncomingSapronaksTable = ({
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } = const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR( useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`, `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakFetcher, ClosingApi.getAllIncomingSapronakFetcher,
{ {
keepPreviousData: true, keepPreviousData: true,
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronakSummary } from '@/types/api/closing';
interface ClosingOutgoingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: outgoingSapronakSummaries,
isLoading: isLoadingOutgoingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries.data.length > 0
: false
);
}
}, [outgoingSapronakSummaries, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Ringkasan Sapronak Keluar</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<Table<ClosingOutgoingSapronakSummary>
data={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronakSummaries) &&
outgoingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksSummaryTable;
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table'; import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingOutgoingSapronaksTableProps {
const ClosingOutgoingSapronaksTable = ({ const ClosingOutgoingSapronaksTable = ({
projectFlockId, projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => { }: ClosingOutgoingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -43,7 +47,7 @@ const ClosingOutgoingSapronaksTable = ({
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } = const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR( useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`, `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllOutgoingSapronakFetcher, ClosingApi.getAllOutgoingSapronakFetcher,
{ {
keepPreviousData: true, keepPreviousData: true,
@@ -5,122 +5,187 @@ import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { Overhead, OverheadTotal } from '@/types/api/closing'; import { Overhead, OverheadTotal } from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
interface ClosingOverheadTableProps { interface ClosingOverheadTableProps {
type?: 'detail';
projectFlockId: number; projectFlockId: number;
} }
const ClosingOverheadTable = ({ const ClosingOverheadTable = ({
type,
projectFlockId, projectFlockId,
}: ClosingOverheadTableProps) => { }: ClosingOverheadTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: overhead, isLoading: isLoadingOverhead } = useSWR( const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/overhead`, `${ClosingApi.basePath}/${projectFlockId}${kandangId ? `/${kandangId}` : ''}/overhead`,
() => ClosingApi.getOverhead(projectFlockId), () =>
ClosingApi.getOverhead(
projectFlockId,
kandangId ? Number(kandangId) : undefined
),
{ {
keepPreviousData: true, keepPreviousData: true,
} }
); );
// Helper function to create columns with footer support // Helper function to create columns with footer support
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [ const createColumns = (
// Group untuk kolom tanpa footer total?: OverheadTotal,
{ kandangId?: number
header: 'Nama Item', ): ColumnDef<Overhead>[] => {
accessorFn: (props) => props.item_name, const flockColumn: ColumnDef<Overhead>[] = [
footer: 'Total Pengeluaran Overhead', {
}, header: 'Budget Pengajuan',
{ footer: '',
header: 'Satuan', columns: [
accessorFn: (props) => props.uom_name, {
}, id: 'budget_quantity',
{ header: 'Jumlah',
header: 'Budget Pengajuan', accessorFn: (props) =>
footer: '', props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
columns: [ footer: total ? () => formatNumber(total.budget_quantity) : '',
{ },
id: 'budget_quantity', {
header: 'Jumlah', id: 'budget_unit_price',
accessorFn: (props) => header: 'Harga Satuan',
props.budget_quantity ? formatNumber(props.budget_quantity) : '-', accessorFn: (props) =>
footer: total ? () => formatNumber(total.budget_quantity) : '', props.budget_unit_price
}, ? formatCurrency(props.budget_unit_price)
{ : '-',
id: 'budget_unit_price', footer: '',
header: 'Harga Satuan', },
accessorFn: (props) => {
props.budget_unit_price id: 'budget_total_amount',
? formatCurrency(props.budget_unit_price) header: 'Total',
: '-', accessorFn: (props) =>
footer: '', props.budget_total_amount
}, ? formatCurrency(props.budget_total_amount)
{ : '-',
id: 'budget_total_amount', footer: total
header: 'Total', ? () => formatCurrency(total.budget_total_amount)
accessorFn: (props) => : '',
props.budget_total_amount },
? formatCurrency(props.budget_total_amount) ],
: '-', },
footer: total ? () => formatCurrency(total.budget_total_amount) : '', {
}, header: 'Realisasi',
], footer: '',
}, columns: [
{ {
header: 'Realisasi', id: 'actual_date',
footer: '', header: 'Tanggal',
columns: [ accessorFn: (props) =>
{ props.actual_date
id: 'actual_date', ? formatDate(props.actual_date, 'DD MMM, YYYY')
header: 'Tanggal', : '-',
accessorFn: (props) => footer: '',
props.actual_date },
? formatDate(props.actual_date, 'DD MMM, YYYY') {
: '-', id: 'actual_quantity',
footer: '', header: 'Jumlah',
}, accessorFn: (props) =>
{ props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
id: 'actual_quantity', footer: total ? () => formatNumber(total.actual_quantity) : '',
header: 'Jumlah', },
accessorFn: (props) => {
props.actual_quantity ? formatNumber(props.actual_quantity) : '-', id: 'actual_unit_price',
footer: total ? () => formatNumber(total.actual_quantity) : '', header: 'Harga Satuan',
}, accessorFn: (props) =>
{ props.actual_unit_price
id: 'actual_unit_price', ? formatCurrency(props.actual_unit_price)
header: 'Harga Satuan', : '-',
accessorFn: (props) => footer: '',
props.actual_unit_price },
? formatCurrency(props.actual_unit_price) {
: '-', id: 'actual_total_amount',
footer: '', header: 'Total',
}, accessorFn: (props) =>
{ props.actual_total_amount
id: 'actual_total_amount', ? formatCurrency(props.actual_total_amount)
header: 'Total', : '-',
accessorFn: (props) => footer: total
props.actual_total_amount ? () => formatCurrency(total.actual_total_amount)
? formatCurrency(props.actual_total_amount) : '',
: '-', },
footer: total ? () => formatCurrency(total.actual_total_amount) : '', ],
}, },
], ];
},
{ const kandangColumn: ColumnDef<Overhead>[] = [
id: 'cost_per_bird', {
header: 'Rp/Ekor', id: 'actual_date',
accessorFn: (props) => header: 'Tanggal',
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-', accessorFn: (props) =>
footer: total ? () => formatCurrency(total.cost_per_bird) : '', props.actual_date
}, ? formatDate(props.actual_date, 'DD MMM, YYYY')
]; : '-',
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.actual_unit_price
? formatCurrency(props.actual_unit_price)
: '-',
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) =>
props.actual_total_amount
? formatCurrency(props.actual_total_amount)
: '-',
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
},
];
const finalColumns: ColumnDef<Overhead>[] = [
// Group untuk kolom tanpa footer
{
header: 'No',
accessorFn: (_, index) => index,
cell: (props) => props.row.index + 1,
},
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
...(kandangId ? kandangColumn : flockColumn),
{
id: 'cost_per_bird',
header: 'Rp/Ekor',
accessorFn: (props) =>
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
},
];
return finalColumns;
};
const columns = useMemo( const columns = useMemo(
() => () =>
isResponseSuccess(overhead) isResponseSuccess(overhead)
? createColumns(overhead.data?.total) ? createColumns(
overhead.data?.total,
kandangId ? Number(kandangId) : undefined
)
: createColumns(), : createColumns(),
[overhead] [overhead]
); );
@@ -148,6 +213,7 @@ const ClosingOverheadTable = ({
'whitespace-nowrap' 'whitespace-nowrap'
), ),
}} }}
isLoading={isLoadingOverhead}
renderFooter={ renderFooter={
isResponseSuccess(overhead) isResponseSuccess(overhead)
? overhead.data?.overheads.length > 0 ? overhead.data?.overheads.length > 0
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -12,9 +13,12 @@ interface ClosingProductionDataTabContentProps {
const ClosingProductionDataTabContent = ({ const ClosingProductionDataTabContent = ({
projectFlockId, projectFlockId,
}: ClosingProductionDataTabContentProps) => { }: ClosingProductionDataTabContentProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: productionData, isLoading } = useSWR( const { data: productionData, isLoading } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/production-data`, `${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
() => ClosingApi.getProductionData(projectFlockId) () => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
); );
if (isLoading) { if (isLoading) {
@@ -197,7 +201,7 @@ const ClosingProductionDataTabContent = ({
value={formatNumber(performance.mor_diff)} value={formatNumber(performance.mor_diff)}
unitClassName='hidden' unitClassName='hidden'
/> />
<DataRow {/* <DataRow
label='AWG Std' label='AWG Std'
value={formatNumber(performance.awg_std)} value={formatNumber(performance.awg_std)}
unit='Gr/Hari' unit='Gr/Hari'
@@ -206,7 +210,7 @@ const ClosingProductionDataTabContent = ({
label='AWG Act' label='AWG Act'
value={formatNumber(performance.awg_act)} value={formatNumber(performance.awg_act)}
unit='Gr/Hari' unit='Gr/Hari'
/> /> */}
<DataRow <DataRow
label='Feed Intake Std' label='Feed Intake Std'
value={formatNumber(performance.feed_intake_std)} value={formatNumber(performance.feed_intake_std)}
@@ -2,6 +2,8 @@
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable'; import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable'; import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingIncomingSapronaksSummaryTable from '@/components/pages/closing/ClosingIncomingSapronaksSummaryTable';
import ClosingOutgoingSapronaksSummaryTable from './ClosingOutgoingSapronaksSummaryTable';
interface ClosingSapronakTableProps { interface ClosingSapronakTableProps {
projectFlockId?: number; projectFlockId?: number;
@@ -16,7 +18,15 @@ const ClosingSapronakTabContent = ({
<> <>
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} /> <ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
<ClosingIncomingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} /> <ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
<ClosingOutgoingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
</> </>
)} )}
</div> </div>
@@ -104,6 +104,10 @@ const ClosingsTable = () => {
header: '#', header: '#',
cell: (props) => props.row.index + 1, cell: (props) => props.row.index + 1,
}, },
{
accessorKey: 'project_name',
header: 'Flock',
},
{ {
accessorKey: 'location_name', accessorKey: 'location_name',
header: 'Lokasi', header: 'Lokasi',
@@ -163,6 +167,7 @@ const ClosingsTable = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
@@ -228,6 +233,7 @@ const ClosingsTable = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6', wrapper: 'col-span-12 sm:col-span-6',
@@ -13,7 +13,6 @@ interface HppExpeditionReportTableProps {
} }
const HppExpeditionReportTable = ({ const HppExpeditionReportTable = ({
type = 'detail',
initialValues, initialValues,
}: HppExpeditionReportTableProps) => { }: HppExpeditionReportTableProps) => {
const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => { const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => {
@@ -4,9 +4,12 @@ import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Badge from '@/components/Badge';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper'; import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import { BaseClosingSales, BaseSales } from '@/types/api/closing'; import {
BaseClosingSales,
BaseSales,
ClosingSalesSummary,
} from '@/types/api/closing';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer'; import { Customer } from '@/types/api/master-data/customer';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
@@ -16,22 +19,25 @@ interface SalesReportTableProps {
initialValues?: BaseClosingSales; initialValues?: BaseClosingSales;
} }
const SalesReportTable = ({ const SalesReportTable = ({ initialValues }: SalesReportTableProps) => {
type = 'detail',
initialValues,
}: SalesReportTableProps) => {
const salesData: BaseSales[] = useMemo(() => { const salesData: BaseSales[] = useMemo(() => {
return initialValues?.sales || []; return initialValues?.sales || [];
}, [initialValues]); }, [initialValues]);
const summary: ClosingSalesSummary | undefined = useMemo(() => {
return initialValues?.summary;
}, [initialValues]);
const totals = useMemo(() => { const totals = useMemo(() => {
if (salesData.length === 0) { if (salesData.length === 0) {
return { return {
totalQuantity: 0, totalQuantity: 0,
totalWeight: 0, totalWeight: 0,
avgWeight: 0, avgWeight: 0,
avgPricePartner: 0, avgSalesPrice: 0,
totalPartner: 0, totalSalesPrice: 0,
avgActualPrice: 0,
totalActualPrice: 0,
}; };
} }
@@ -45,26 +51,46 @@ const SalesReportTable = ({
); );
const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0; const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0;
const validPriceItems = salesData.filter( const totalSalesPrice = salesData.reduce(
(item) => item.price != null && item.price > 0 (sum, item) => sum + (item.total_sales_price || 0),
);
const avgPricePartner =
validPriceItems.length > 0
? validPriceItems.reduce((sum, item) => sum + item.price, 0) /
validPriceItems.length
: 0;
const totalPartner = salesData.reduce(
(sum, item) => sum + (item.total_price || 0),
0 0
); );
const validSalesPriceItems = salesData.filter(
(item) => item.sales_price != null && item.sales_price > 0
);
const avgSalesPrice =
validSalesPriceItems.length > 0
? validSalesPriceItems.reduce(
(sum, item) => sum + item.sales_price,
0
) / validSalesPriceItems.length
: 0;
const totalActualPrice = salesData.reduce(
(sum, item) => sum + (item.total_actual_price || 0),
0
);
const validActualPriceItems = salesData.filter(
(item) => item.actual_price != null && item.actual_price > 0
);
const avgActualPrice =
validActualPriceItems.length > 0
? validActualPriceItems.reduce(
(sum, item) => sum + item.actual_price,
0
) / validActualPriceItems.length
: 0;
return { return {
totalQuantity, totalQuantity,
totalWeight, totalWeight,
avgWeight, avgWeight,
avgPricePartner, avgSalesPrice,
totalPartner, totalSalesPrice,
avgActualPrice,
totalActualPrice,
}; };
}, [salesData]); }, [salesData]);
@@ -86,7 +112,11 @@ const SalesReportTable = ({
id: 'age', id: 'age',
accessorKey: 'age', accessorKey: 'age',
header: 'Umur', header: 'Umur',
cell: (props) => props.getValue() || '-', cell: (props) => {
const age = props.row.original.age;
const week = props.row.original.week;
return age && week ? `${age} hari (${week} minggu)` : '-';
},
}, },
{ {
id: 'do_number', id: 'do_number',
@@ -161,50 +191,68 @@ const SalesReportTable = ({
), ),
}, },
{ {
id: 'price_partner', id: 'sales_price',
accessorKey: 'price', accessorKey: 'sales_price',
header: 'Harga Mitra (Rp)', header: 'Harga Sales (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
}, },
footer: () => ( footer: () => (
<div className='text-right font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.avgPricePartner)} {summary
? formatCurrency(summary.avg_sales_price)
: formatCurrency(totals.avgSalesPrice)}
</div> </div>
), ),
}, },
{ {
id: 'total_mitra', id: 'total_sales_price',
accessorKey: 'total_price', accessorKey: 'total_sales_price',
header: 'Total Mitra (Rp)', header: 'Total Sales (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
}, },
footer: () => ( footer: () => (
<div className='text-right font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalPartner)} {summary
? formatCurrency(summary.total_sales_price)
: formatCurrency(totals.totalSalesPrice)}
</div> </div>
), ),
}, },
{ {
id: 'price_act', id: 'actual_price',
accessorKey: 'price', accessorKey: 'actual_price',
header: 'Harga Act (Rp)', header: 'Harga Act (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
}, },
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summary
? formatCurrency(summary.avg_actual_price)
: formatCurrency(totals.avgActualPrice)}
</div>
),
}, },
{ {
id: 'total_act', id: 'total_actual_price',
accessorKey: 'total_price', accessorKey: 'total_actual_price',
header: 'Total Act (Rp)', header: 'Total Act (Rp)',
cell: (props) => { cell: (props) => {
const value = props.getValue() as number; const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
}, },
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{summary
? formatCurrency(summary.total_actual_price)
: formatCurrency(totals.totalActualPrice)}
</div>
),
}, },
{ {
id: 'kandang', id: 'kandang',
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,11 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import { OptionType } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import { formatNumber } from '@/lib/helper';
import { import {
Dashboard, Dashboard,
DashboardOverviewCharts, DashboardOverviewCharts,
@@ -25,20 +28,29 @@ import {
type DashboardLineChartProps = { type DashboardLineChartProps = {
analysisMode: 'OVERVIEW' | 'COMPARISON'; analysisMode: 'OVERVIEW' | 'COMPARISON';
data: Dashboard; data: Dashboard;
selectedKandang?: OptionType;
}; };
// Type guard to check if charts is DashboardOverviewCharts // Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts( function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts { ): charts is DashboardOverviewCharts {
return 'deplesi' in charts; if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
} }
// Type guard to check if charts is DashboardComparisonCharts // Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts( function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts { ): charts is DashboardComparisonCharts {
return 'location' in charts || 'flock' in charts || 'kandang' in charts; if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
} }
const lineColors: Record<string, string> = { const lineColors: Record<string, string> = {
@@ -94,6 +106,7 @@ const getLineColor = (
const DashboardLineChart = ({ const DashboardLineChart = ({
analysisMode, analysisMode,
data, data,
selectedKandang,
}: DashboardLineChartProps) => { }: DashboardLineChartProps) => {
const [chartData, setChartData] = const [chartData, setChartData] =
useState<keyof DashboardOverviewCharts>('body_weight'); useState<keyof DashboardOverviewCharts>('body_weight');
@@ -123,7 +136,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts) isComparisonCharts(data.charts)
) { ) {
const comparisonChart = const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang; data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || []; seriesData = comparisonChart?.series || [];
} }
@@ -135,11 +148,12 @@ const DashboardLineChart = ({
return ( return (
<Card <Card
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-lg p-0',
body: 'p-4',
}} }}
variant='bordered' variant='bordered'
> >
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'> <div className='flex flex-col sm:flex-row justify-between items-start gap-4 mb-3'>
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
Performance{' '} Performance{' '}
<Icon <Icon
@@ -153,26 +167,28 @@ const DashboardLineChart = ({
<Dropdown <Dropdown
align='end' align='end'
direction='bottom' direction='bottom'
className={{
content: 'mt-1 min-w-full',
}}
trigger={ trigger={
<Button <Button
variant='outline' variant='outline'
color='none' color='none'
className='text-neutral-500 hover:text-neutral-700 rounded-lg px-4 py-2 border-neutral-300' className='py-2.5 pl-3 pr-2 text-base-content/50 rounded-lg text-sm border-base-content/10 shadow-button-soft'
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
{chartTypeLabels[chartData]}{' '} {chartTypeLabels[chartData]}{' '}
<div className='divider divider-horizontal p-0 m-0 before:bg-neutral-300 after:bg-neutral-300'></div> <div className='w-6 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon icon='heroicons:chevron-down' width={20} height={20} /> <Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button> </Button>
} }
className={{
content: 'w-52 mt-3',
}}
controlled={open} controlled={open}
> >
<Menu> <Menu className='p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg'>
<MenuItem <MenuItem
title='Body weight' title='Body weight'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('body_weight'); setChartData('body_weight');
setOpen(!open); setOpen(!open);
@@ -180,6 +196,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Performance' title='Performance'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('performance'); setChartData('performance');
setOpen(!open); setOpen(!open);
@@ -187,6 +204,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='FCR' title='FCR'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('fcr'); setChartData('fcr');
setOpen(!open); setOpen(!open);
@@ -194,6 +212,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Quality Control' title='Quality Control'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('quality_control'); setChartData('quality_control');
setOpen(!open); setOpen(!open);
@@ -201,6 +220,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Deplesi' title='Deplesi'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('deplesi'); setChartData('deplesi');
setOpen(!open); setOpen(!open);
@@ -224,7 +244,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts) isComparisonCharts(data.charts)
) { ) {
const comparisonChart = const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang; data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || []; seriesData = comparisonChart?.series || [];
} }
@@ -236,8 +256,8 @@ const DashboardLineChart = ({
.includes('std'); .includes('std');
return ( return (
<button <Button
key={series.id} key={`${series.id}-${index}`}
onClick={() => { onClick={() => {
const newVisible = new Set(visibleSeries); const newVisible = new Set(visibleSeries);
if (isVisible) { if (isVisible) {
@@ -247,14 +267,16 @@ const DashboardLineChart = ({
} }
setVisibleSeries(newVisible); setVisibleSeries(newVisible);
}} }}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${ variant='outline'
color='none'
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
isVisible isVisible
? 'border-neutral-400 bg-neutral-50' ? 'border-base-content/10 hover:bg-base-content/4'
: 'border-neutral-300 hover:bg-neutral-50' : 'border-base-content/10 bg-base-content/4'
}`} }`}
> >
<div <div
className={`w-6 h-0.5 ${ className={`w-5 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : '' isStandard ? 'border-t-2 border-dashed' : ''
} ${!isVisible ? 'opacity-30' : ''}`} } ${!isVisible ? 'opacity-30' : ''}`}
style={{ style={{
@@ -267,277 +289,457 @@ const DashboardLineChart = ({
}} }}
></div> ></div>
<span <span
className={`text-sm ${isVisible ? 'text-neutral-900 font-medium' : 'text-neutral-700'}`} className={`font-semibold text-sm ${isVisible ? 'text-base-content/50' : 'text-base-content/50'}`}
> >
{series.label} {series.label}
</span> </span>
<Icon <Icon
icon='heroicons:information-circle' icon='heroicons:eye'
width={16} width={16}
height={16} height={16}
className='text-neutral-400' className='text-base-content/40'
/> />
</button> </Button>
); );
}); });
})()} })()}
</div> </div>
{/* Chart */} {/* Chart Container with Empty State Overlay */}
<ResponsiveContainer width='100%' height={350}> <div className='relative'>
<LineChart {/* Chart */}
data={(() => { <ResponsiveContainer width='100%' height={350}>
// Transform data based on analysisMode <LineChart
if (analysisMode === 'OVERVIEW') { data={(() => {
// For OVERVIEW mode, use the selected chart data // Transform data based on analysisMode
if (isOverviewCharts(data.charts)) { if (analysisMode === 'OVERVIEW') {
const selectedChartData = data.charts[chartData]; // For OVERVIEW mode, use the selected chart data
if (!selectedChartData || !selectedChartData.dataset) return []; if (isOverviewCharts(data.charts)) {
return selectedChartData.dataset; const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
} }
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()} })()}
ticks={(() => { margin={{
// Calculate dynamic ticks based on domain top: 5,
let seriesData: DashboardChartsSeries[] = []; right: 10,
let dataset: DashboardChartsDataset[] = []; left: 0,
bottom: 5,
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}} }}
labelStyle={{ color: 'white', marginBottom: '4px' }} >
itemStyle={{ color: 'white', fontSize: '12px' }} <CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
labelFormatter={(value) => `Week ${value}`} <XAxis
formatter={( dataKey='week'
value: number | undefined, tick={{
name: string | undefined fontSize: 12,
) => { fill: '#18181B',
if (value === undefined || name === undefined) return ['', '']; opacity: 0.5,
fontWeight: 600,
// Get series data to find the unit }}
let seriesData: DashboardChartsSeries[] = []; tickLine={false}
if ( axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
analysisMode === 'OVERVIEW' && label={{
isOverviewCharts(data.charts) value: 'Weeks',
) { position: 'insideBottom',
seriesData = data.charts[chartData]?.series || []; offset: -5,
} else if ( style: {
analysisMode === 'COMPARISON' && fontSize: 12,
isComparisonCharts(data.charts) fill: '#18181B',
) { opacity: 0.2,
const comparisonChart = fontWeight: 600,
data.charts.location || },
data.charts.flock || }}
data.charts.kandang; />
seriesData = comparisonChart?.series || []; <YAxis
} tick={{
fontSize: 12,
// Find the series that matches this line's name fill: '#18181B',
const series = seriesData.find((s) => s.label === name); opacity: 0.5,
const unit = series?.unit || ''; fontWeight: 600,
}}
return [`${value} ${unit}`, name]; label={
}} (chartData === 'body_weight' || chartData === 'performance') &&
/> analysisMode === 'OVERVIEW'
{/* Dynamic Line rendering based on visible series */} ? {
{(() => { value:
let seriesData: DashboardChartsSeries[] = []; chartData === 'body_weight'
? 'Body Weight'
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) { : 'Percentage',
seriesData = data.charts[chartData]?.series || []; position: 'insideLeft',
} else if ( angle: -90,
analysisMode === 'COMPARISON' && offset: 5,
isComparisonCharts(data.charts) style: {
) { fontSize: 12,
const comparisonChart = fill: '#18181B',
data.charts.location || opacity: 0.2,
data.charts.flock || fontWeight: 600,
data.charts.kandang; },
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
} }
activeDot={isStandard ? undefined : { r: 5 }} : analysisMode === 'COMPARISON'
/> ? {
value: 'Percentage',
position: 'insideLeft',
angle: -90,
offset: 5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}
: undefined
}
tickLine={false}
axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Handle edge case where min equals max
if (minValue === maxValue) {
const value = Math.round(minValue);
const padding = Math.max(10, Math.abs(value) * 0.2);
return [
Math.floor(value - padding),
Math.floor(value - padding / 2),
value,
Math.ceil(value + padding / 2),
Math.ceil(value + padding),
];
}
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
// Use Set to ensure unique values
const tickSet = new Set([
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
]);
return Array.from(tickSet).sort((a, b) => a - b);
})()}
tickFormatter={(value) => formatNumber(Number(value))}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '12px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
content={(props) => {
return (
<div className='flex flex-col gap-1.5 rounded-lg bg-neutral-950 p-4 text-white'>
<p className='text-white/50 text-xs font-semibold text-start'>
{analysisMode === 'OVERVIEW'
? selectedKandang
? selectedKandang.label || 'Overview Performance'
: 'Overview Performance'
: 'Comparison Performance'}
</p>
<ul className='flex flex-col gap-1'>
{props.payload.map((item, index) => {
if (item.name.startsWith('STD. ')) return null;
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find(
(s) => s.label === item.name
);
const color = series?.id
? getLineColor(series.id, index, analysisMode)
: '#9ca3af';
const unit = series?.unit;
return (
<li
key={`${item.name}-${index}`}
className='flex w-full justify-between items-center flex-row gap-y-1.5 gap-x-3 p-0'
>
<span className='flex flex-row gap-1.5 items-center'>
<div
className='h-5 w-5 m-0 rounded'
style={{
backgroundColor: color,
}}
></div>
<div className='m-0'>
{formatNumber(item.value)}
{unit}
</div>
</span>
<span className='m-0'>{item.name}</span>
</li>
);
})}
</ul>
<p className='text-white/50 text-xs text-start'>
Week {props.label}
</p>
</div>
); );
}); }}
})()} formatter={(
</LineChart> value: number | undefined,
</ResponsiveContainer> name: string | undefined
) => {
if (
value === undefined ||
name === undefined ||
name.startsWith('STD. ')
)
return [undefined, undefined];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const id = series?.id || '';
return [value, id];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={`${series.id}--${index}`}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
{/* Empty State Overlay */}
{(() => {
// Get current dataset
let dataset: DashboardChartsDataset[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
// Show empty state if dataset is empty
if (dataset.length === 0) {
return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */}
<DataStateSkeleton
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
description='Please change your filters to get the data.'
/>
</div>
);
}
return null;
})()}
</div>
</Card> </Card>
); );
}; };
@@ -21,7 +21,7 @@ const CARD_CONFIG = [
key: 'Avg. Selling Price', key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar', icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const, alertColor: 'success' as const,
suffix: ' /Kg', suffix: ' /Kg Telur',
prefix: '', prefix: '',
}, },
{ {
@@ -48,7 +48,7 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
icon: isPositive icon: isPositive
? 'heroicons:arrow-trending-up' ? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down', : 'heroicons:arrow-trending-down',
color: isPositive ? 'text-success' : 'text-error', color: isPositive ? 'text-[#008000]' : 'text-[#FF3A3A]',
value: Math.abs(percent), value: Math.abs(percent),
}; };
}; };
@@ -60,14 +60,16 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
{prefix} {prefix}
{formatNumber(value)} {formatNumber(value)}
{suffix && ( {suffix && (
<span className='text-sm font-normal text-neutral-500'>{suffix}</span> <span className='text-sm font-normal text-base-content/50'>
{suffix}
</span>
)} )}
</> </>
); );
}; };
return ( return (
<div className='grid sm:grid-cols-2 xl:grid-cols-4 gap-6'> <div className='grid sm:grid-cols-2 xl:grid-cols-4 gap-3'>
{CARD_CONFIG.map((config) => { {CARD_CONFIG.map((config) => {
// Find matching data from API // Find matching data from API
const cardData = data.find((item) => item.label === config.key); const cardData = data.find((item) => item.label === config.key);
@@ -78,35 +80,41 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Card <Card
key={config.key} key={config.key}
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-xl border border-base-content/10',
body: 'p-0', body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}} }}
variant='bordered' variant='bordered'
footer={ footer={
<div className='flex flex-row justify-between px-4 pb-4'> <div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-neutral-400 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
From last month From last month
</div> </div>
<div className='text-neutral-400 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
Filter Required Filter Required
</div> </div>
</div> </div>
} }
> >
<div className='flex flex-row items-center gap-4 px-4 pt-4'> <div className='flex flex-row items-center gap-3 px-4 py-4'>
<Alert variant='soft' className='rounded-lg p-3 bg-neutral-100'> <Alert
variant='soft'
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon <Icon
icon={config.icon} icon={config.icon}
width={32} width={24}
height={32} height={24}
className='text-neutral-400' className='text-base-content/50'
/> />
</Alert> </Alert>
<div> <div>
<h3 className='text-neutral-400 font-semibold text-sm'> <h3 className='text-base-content/50 font-semibold text-sm'>
{config.key} {config.key}
</h3> </h3>
<p className='text-2xl font-semibold text-neutral-400'> <p className='text-xl font-semibold text-base-content/50'>
******** ********
</p> </p>
</div> </div>
@@ -121,17 +129,20 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Card <Card
key={config.key} key={config.key}
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-xl border border-base-content/10',
body: 'p-0', body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}} }}
variant='bordered' variant='bordered'
footer={ footer={
<div className='flex flex-row justify-between px-4 pb-4'> <div className='flex flex-row justify-between px-4 pb-4'>
<div className='text-neutral-500 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
From last month From last month
</div> </div>
<div <div
className={`${trend.color} font-semibold flex flex-row items-center gap-1 text-sm`} className={`${trend.color} font-semibold flex flex-row items-center gap-2 text-xs`}
> >
<Icon icon={trend.icon} width={16} height={16} /> <Icon icon={trend.icon} width={16} height={16} />
{trend.value}% {trend.value}%
@@ -143,15 +154,15 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Alert <Alert
variant='soft' variant='soft'
color={config.alertColor} color={config.alertColor}
className='rounded-lg p-3' className={`rounded-lg p-3 bg-[${config.alertColor}]/12 flex items-center justify-center`}
> >
<Icon icon={config.icon} width={32} height={32} /> <Icon icon={config.icon} width={24} height={24} />
</Alert> </Alert>
<div> <div className='space-y-1'>
<h3 className='text-neutral-500 font-semibold text-sm'> <h3 className='text-base-content/50 font-semibold text-sm'>
{cardData.label} {cardData.label}
</h3> </h3>
<p className='text-2xl font-semibold'> <p className='text-xl font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)} {formatValue(cardData.value, config.prefix, config.suffix)}
</p> </p>
</div> </div>
@@ -0,0 +1,344 @@
import Card from '@/components/Card';
import {
Dashboard,
DashboardOverviewCharts,
DashboardComparisonCharts,
DashboardChartsSeries,
DashboardChartsDataset,
} from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type DashboardExportChartsProps = {
data: Dashboard;
analysisMode: string;
};
export type DashboardExportChartsRef = {
getChartRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
};
// Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts {
if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
}
// Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts {
if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
}
const lineColors: Record<string, string> = {
body_weight: '#10B981',
std_body_weight: '#10B981',
act_laying: '#1062B9',
std_laying: '#1062B9',
act_egg_weight: '#10B981',
std_egg_weight: '#10B981',
act_feed_intake: '#F52419',
std_feed_intake: '#F52419',
act_uniformity: '#F59E0B',
std_uniformity: '#F59E0B',
act_fcr: '#10B981',
std_fcr: '#10B981',
act_fcr_cum: '#F52419',
std_fcr_cum: '#10B981',
normal: '#10B981',
abnormal: '#F52419',
act_deplesi: '#10B981',
std_deplesi: '#10B981',
};
const defaultLineColors: string[] = [
'#10B981',
'#1062B9',
'#F52419',
'#F59E0B',
'#7F56D9',
];
// Helper function to get line color
const getLineColor = (seriesId: string | number, index: number): string => {
const predefinedColor = lineColors[seriesId];
if (predefinedColor) {
return predefinedColor;
}
return defaultLineColors[index % defaultLineColors.length];
};
// Mapping for chart type labels
const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
body_weight: 'Body Weight',
performance: 'Performance',
fcr: 'FCR',
quality_control: 'Quality Control',
deplesi: 'Deplesi',
};
const DashboardExportCharts = forwardRef<
DashboardExportChartsRef,
DashboardExportChartsProps
>(({ data, analysisMode }, ref) => {
// Create refs for charts - use string keys for flexibility
const chartRefs = useRef<{
[key: string]: HTMLDivElement | null;
}>({});
// Determine chart keys and labels based on analysis mode
const getChartConfig = () => {
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
const overviewKeys: (keyof DashboardOverviewCharts)[] = [
'body_weight',
'performance',
'fcr',
'quality_control',
'deplesi',
];
return overviewKeys.map((key) => ({
key,
label: chartTypeLabels[key],
chartData: (data.charts as DashboardOverviewCharts)[key],
}));
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
// For comparison mode, find which comparison type has data
const comparisonKey = data.charts.farm
? 'farm'
: data.charts.flock
? 'flock'
: 'kandang';
const comparisonLabels: Record<string, string> = {
farm: 'Farm Comparison',
flock: 'Flock Comparison',
kandang: 'Kandang Comparison',
};
return [
{
key: comparisonKey,
label: comparisonLabels[comparisonKey],
chartData: data.charts[comparisonKey],
},
];
}
return [];
};
const chartConfig = getChartConfig();
// Expose method to get all chart refs
useImperativeHandle(ref, () => ({
getChartRefs: () => {
return chartConfig
.map(({ key, label }) => ({
key,
ref: chartRefs.current[key] || null,
label,
}))
.filter((item) => item.ref !== null);
},
}));
return (
<div className='space-y-6'>
{chartConfig.map(({ key, label, chartData }) => {
if (
!chartData ||
!chartData.dataset ||
chartData.dataset.length === 0
) {
return null;
}
const seriesData: DashboardChartsSeries[] = chartData.series || [];
const dataset: DashboardChartsDataset[] = chartData.dataset || [];
return (
<div
key={key}
ref={(el: HTMLDivElement | null) => {
chartRefs.current[key] = el;
}}
>
<Card
className={{
wrapper: 'w-full rounded-lg p-0',
body: 'p-4',
}}
variant='bordered'
>
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'>
<div className='text-lg font-semibold'>
{label}{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
</div>
{/* Legend */}
<div className='flex flex-wrap gap-3 mb-6'>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
return (
<div
key={series.id}
className='flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-400 bg-neutral-50'
>
<div
className={`w-6 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : ''
}`}
style={{
backgroundColor: isStandard
? 'transparent'
: getLineColor(series.id, index),
borderColor: isStandard
? getLineColor(series.id, index)
: 'transparent',
}}
></div>
<span className='text-sm text-neutral-900 font-medium'>
{series.label}
</span>
<Icon
icon='heroicons:information-circle'
width={16}
height={16}
className='text-neutral-400'
/>
</div>
);
})}
</div>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={dataset}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
seriesData.forEach((series) => {
const value = item[series.id];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(
Math.max(0, minValue - padding)
);
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
/>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(series.id, index),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</Card>
</div>
);
})}
</div>
);
});
DashboardExportCharts.displayName = 'DashboardExportCharts';
export default DashboardExportCharts;
@@ -0,0 +1,197 @@
import Alert from '@/components/Alert';
import Card from '@/components/Card';
import { formatNumber } from '@/lib/helper';
import { DashboardStatisticsData } from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
interface DashboardStatsProps {
data: DashboardStatisticsData[];
}
export type DashboardExportStatsRef = {
getStatsRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
getContainerRef: () => HTMLDivElement | null;
};
// Konfigurasi untuk setiap kartu
const CARD_CONFIG = [
{
key: 'HPP Global',
icon: 'heroicons:banknotes',
alertColor: 'warning' as const,
suffix: ' /Kg',
prefix: 'RP ',
},
{
key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const,
suffix: ' /Kg',
prefix: '',
},
{
key: 'FCR',
icon: 'heroicons:clipboard-document-list',
alertColor: 'info' as const,
suffix: '',
prefix: '',
},
{
key: 'Mortality',
icon: 'heroicons:exclamation-triangle',
alertColor: 'error' as const,
suffix: ' %',
prefix: '',
},
];
const DashboardExportStats = forwardRef<
DashboardExportStatsRef,
DashboardStatsProps
>(({ data }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
// Helper to get trend icon and color
const getTrendDisplay = (percent: number) => {
const isPositive = percent >= 0;
return {
icon: isPositive
? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down',
color: isPositive ? 'text-[#008000]' : 'text-[#FF3A3A]',
value: Math.abs(percent),
};
};
// Helper to format value
const formatValue = (value: number, prefix: string, suffix: string) => {
return (
<>
{prefix}
{formatNumber(value)}
{suffix && (
<span className='text-sm font-normal text-base-content/50'>
{suffix}
</span>
)}
</>
);
};
// Expose container ref through imperative handle
useImperativeHandle(ref, () => ({
getStatsRefs: () => [],
getContainerRef: () => containerRef.current,
}));
return (
<div ref={containerRef} className='grid grid-cols-4 gap-3'>
{CARD_CONFIG.map((config) => {
// Find matching data from API
const cardData = data.find((item) => item.label === config.key);
if (!cardData) {
// Show placeholder card for missing data (FCR & Mortality)
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-base-content/50 font-semibold text-xs'>
From last month
</div>
<div className='text-base-content/50 font-semibold text-xs'>
Filter Required
</div>
</div>
}
>
<div className='flex flex-row items-center gap-3 px-4 pt-4'>
<Alert
variant='soft'
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon
icon={config.icon}
width={24}
height={24}
className='text-base-content/50'
/>
</Alert>
<div>
<h3 className='text-base-content/50 font-semibold text-sm'>
{config.key}
</h3>
<p className='text-xl font-semibold text-base-content/50'>
********
</p>
</div>
</div>
</Card>
);
}
const trend = getTrendDisplay(cardData.percent_last_month);
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg border border-base-content/10',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-base-content/50 font-semibold text-xs'>
From last month
</div>
<div
className={`${trend.color} font-semibold flex flex-row items-center gap-2 text-xs`}
>
<Icon icon={trend.icon} width={16} height={16} />
{trend.value}%
</div>
</div>
}
>
<div className='flex flex-row items-center gap-4 px-4 pt-4'>
<Alert
variant='soft'
color={config.alertColor}
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon icon={config.icon} width={24} height={24} />
</Alert>
<div>
<h3 className='text-base-content/50 font-semibold text-sm'>
{cardData.label}
</h3>
<p className='text-xl font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)}
</p>
</div>
</div>
</Card>
);
})}
</div>
);
});
DashboardExportStats.displayName = 'DashboardExportStats';
export default DashboardExportStats;
@@ -0,0 +1,266 @@
import jsPDF from 'jspdf';
import { toPng } from 'html-to-image';
import toast from 'react-hot-toast';
import { formatDate } from '@/lib/helper';
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import { DashboardExportChartsRef } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { DashboardExportStatsRef } from '@/components/pages/dashboard/export/DashboardExportStats';
interface DashboardPDFExportParams {
filterValues: DashboardFilterType;
allStatsRef: React.RefObject<DashboardExportStatsRef | null>;
allChartsRef: React.RefObject<DashboardExportChartsRef | null>;
setExporting: (value: boolean) => void;
}
export const generateDashboardPDF = async ({
filterValues,
allStatsRef,
allChartsRef,
setExporting,
}: DashboardPDFExportParams): Promise<void> => {
try {
setExporting(true);
toast.loading('Generating PDF...', { id: 'export-pdf' });
// Wait for DOM to update
await new Promise((resolve) => setTimeout(resolve, 200));
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 10;
let yPosition = margin;
// Add title
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text('Dashboard Produksi', margin, yPosition);
yPosition += 10;
// Add filter information (horizontal layout)
pdf.setFontSize(6);
pdf.setFont('helvetica', 'normal');
const filterItems: string[] = [];
// Period
if (filterValues.startDate || filterValues.endDate) {
const periodText = `Periode: ${
filterValues.startDate
? formatDate(filterValues.startDate, 'DD MMM YYYY')
: '-'
} s.d ${
filterValues.endDate
? formatDate(filterValues.endDate, 'DD MMM YYYY')
: '-'
}`;
filterItems.push(periodText);
}
// Analysis Mode
const analysisModeText = `Analysis Mode: ${
filterValues.analysisMode === 'OVERVIEW'
? 'Performance Overview'
: 'Performance Comparison'
}`;
filterItems.push(analysisModeText);
// Comparison Type (only for COMPARISON mode)
if (
filterValues.analysisMode === 'COMPARISON' &&
filterValues.comparisonType
) {
const comparisonTypeLabel =
filterValues.comparisonType === 'FARM'
? 'Farm'
: filterValues.comparisonType === 'FLOCK'
? 'Flock'
: filterValues.comparisonType === 'KANDANG'
? 'Kandang'
: filterValues.comparisonType;
filterItems.push(`Compared By: ${comparisonTypeLabel}`);
}
// Farm
if (filterValues.location) {
const locationText = Array.isArray(filterValues.location)
? filterValues.location.map((loc) => loc.label).join(', ')
: filterValues.location.label;
filterItems.push(`Farm: ${locationText || '-'}`);
}
// Flock
if (
filterValues.flock &&
(Array.isArray(filterValues.flock)
? filterValues.flock.length > 0
: filterValues.flock)
) {
const flockText = Array.isArray(filterValues.flock)
? filterValues.flock.map((f) => f.label).join(', ')
: filterValues.flock.label;
filterItems.push(`Flock: ${flockText || '-'}`);
}
// Kandang
if (
filterValues.kandang &&
(Array.isArray(filterValues.kandang)
? filterValues.kandang.length > 0
: filterValues.kandang)
) {
const kandangText = Array.isArray(filterValues.kandang)
? filterValues.kandang.map((k) => k.label).join(', ')
: filterValues.kandang.label;
filterItems.push(`Kandang: ${kandangText || '-'}`);
}
// Generated timestamp
filterItems.push(`Dicetak: ${formatDate(new Date(), 'DD MMM YYYY HH:mm')}`);
// Render filter items horizontally with word wrap and gray background
const maxWidth = pageWidth - 2 * margin;
let currentLine = '';
const lines: string[] = [];
// First pass: calculate all lines
filterItems.forEach((item, index) => {
const separator = index > 0 ? ' | ' : '';
const testLine = currentLine + separator + item;
const testWidth = pdf.getTextWidth(testLine);
if (testWidth > maxWidth && currentLine !== '') {
lines.push(currentLine);
currentLine = item;
} else {
currentLine = testLine;
}
});
// Add last line
if (currentLine) {
lines.push(currentLine);
}
// Calculate background dimensions
const lineHeight = 3;
const padding = 1;
const backgroundHeight = lines.length * lineHeight + padding * 2;
// Draw gray background
pdf.setFillColor(240, 240, 240); // Light gray (RGB: 240, 240, 240)
pdf.rect(
margin - padding,
yPosition - padding - 2,
pageWidth - 2 * margin + padding * 2,
backgroundHeight,
'F'
);
// Render text on top of background
lines.forEach((line, index) => {
pdf.text(line, margin, yPosition);
if (index < lines.length - 1) {
yPosition += lineHeight;
}
});
yPosition += 10;
// Capture and add stats if available
if (allStatsRef.current) {
const statsContainer = allStatsRef.current.getContainerRef();
if (statsContainer) {
const statsImage = await toPng(statsContainer, {
quality: 1,
pixelRatio: 2,
});
const statsImgProps = pdf.getImageProperties(statsImage);
const statsWidth = pageWidth - 2 * margin;
const statsHeight =
(statsImgProps.height * statsWidth) / statsImgProps.width;
// Check if we need a new page
if (yPosition + statsHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
}
pdf.addImage(
statsImage,
'PNG',
margin,
yPosition,
statsWidth,
statsHeight
);
yPosition += statsHeight + 10;
}
}
if (allChartsRef.current) {
// Get all individual chart refs
const chartRefs = allChartsRef.current.getChartRefs();
// Capture each chart separately and add to PDF
for (let i = 0; i < chartRefs.length; i++) {
const { ref: chartElement, label } = chartRefs[i];
if (chartElement) {
// Add chart title
pdf.setFontSize(12);
pdf.setFont('helvetica', 'bold');
const chartImage = await toPng(chartElement, {
quality: 1,
pixelRatio: 2,
});
const chartImgProps = pdf.getImageProperties(chartImage);
const chartWidth = pageWidth - 2 * margin;
const chartHeight =
(chartImgProps.height * chartWidth) / chartImgProps.width;
// Calculate total height needed (title + spacing + chart)
const titleHeight = 10;
const totalHeight = titleHeight + chartHeight;
// Check if chart fits on current page
if (yPosition + totalHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
}
// Add title
pdf.text(label, margin, yPosition);
yPosition += titleHeight;
// Add chart image
pdf.addImage(
chartImage,
'PNG',
margin,
yPosition,
chartWidth,
chartHeight
);
// Update yPosition for next chart (add spacing between charts)
yPosition += chartHeight + 10;
}
}
}
// Save the PDF
const fileName = `dashboard-production-${new Date().toISOString().split('T')[0]}.pdf`;
pdf.save(fileName);
toast.success('PDF exported successfully!', { id: 'export-pdf' });
} catch (error) {
toast.error('Failed to export PDF. Please try again.', {
id: 'export-pdf',
});
} finally {
setExporting(false);
}
};
@@ -7,7 +7,7 @@ export type DashboardFilterType = {
analysisMode: string; analysisMode: string;
comparisonType: string | undefined; comparisonType: string | undefined;
location: OptionType | OptionType[]; location: OptionType | OptionType[];
lokasiIds: number[] | undefined; locationIds: number[] | undefined;
flock: OptionType | OptionType[] | undefined; flock: OptionType | OptionType[] | undefined;
flockIds: number[] | undefined; flockIds: number[] | undefined;
kandang: OptionType | OptionType[] | undefined; kandang: OptionType | OptionType[] | undefined;
@@ -25,7 +25,7 @@ export const DashboardFilterOverviewSchema: yup.ObjectSchema<DashboardFilterType
then: (schema) => schema.required('Compared by is required'), then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(), otherwise: (schema) => schema.optional(),
}), }),
lokasiIds: yup.array().optional(), locationIds: yup.array().optional(),
flockIds: yup.array().optional(), flockIds: yup.array().optional(),
kandangIds: yup.array().optional(), kandangIds: yup.array().optional(),
location: yup location: yup
@@ -68,7 +68,7 @@ export const DashboardFilterComparisonSchema: yup.ObjectSchema<DashboardFilterTy
then: (schema) => schema.required('Compared by is required'), then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(), otherwise: (schema) => schema.optional(),
}), }),
lokasiIds: yup.array().optional(), locationIds: yup.array().optional(),
flockIds: yup.array().optional(), flockIds: yup.array().optional(),
kandangIds: yup.array().optional(), kandangIds: yup.array().optional(),
location: yup location: yup
@@ -1,96 +1,89 @@
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { DashboardMeta } from '@/types/api/dashboard/dashboard'; import { DashboardMeta } from '@/types/api/dashboard/dashboard';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => { const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => {
return ( return (
<div className='w-full bg-white rounded-lg shadow-sm border border-gray-200 p-6 relative'> <div className='w-full bg-white rounded-xl border border-base-content/10 p-4 relative'>
{/* Header with title skeleton */} {/* Header with title skeleton */}
<div className='text-lg font-semibold'> <div className='text-base font-semibold'>
Performance{' '} Performance{' '}
<Icon <Icon
icon='heroicons:information-circle' icon='heroicons:information-circle'
width={20} width={20}
height={20} height={20}
className='inline text-neutral-500' className='inline text-base-content/50'
/> />
</div> </div>
{/* Chart area with axes skeleton */} {/* Chart area with axes skeleton */}
<div className='relative mt-6'> <div className='relative mt-6 '>
{/* Main chart container */} {/* Chart content area */}
<div className='flex gap-4'> <div className='flex-1 relative'>
{/* Y-axis skeleton (left side) */} {/* Empty state centered in chart area */}
<div className='flex flex-col justify-between py-4 space-y-4'> <div className='absolute inset-0 flex flex-col items-center justify-center pb-10'>
{[1, 2, 3, 4, 5, 6].map((item) => ( {!meta?.filters && (
<div <>
key={item} {/* Filter icon */}
className='h-4 w-12 bg-gray-100 rounded animate-pulse' <DataStateSkeleton
></div> icon={
))}
</div>
{/* Chart content area */}
<div className='flex-1 relative'>
{/* Empty state centered in chart area */}
<div className='absolute inset-0 flex flex-col items-center justify-center pb-12'>
{!meta?.filters && (
<>
{/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon <Icon
icon='heroicons:funnel' icon='heroicons:funnel'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/> />
</div> }
title='No Filters Selected'
{/* Empty state text */} description='Please choose filters to narrow down your results and make your search easier.'
<h3 className='text-gray-900 font-semibold text-base mb-2'> />
No Filters Selected </>
</h3> )}
<p className='text-gray-500 text-sm text-center max-w-xs'> {meta?.filters && (
Please choose filters to narrow down your results and make <>
your search easier. {/* Filter icon */}
</p> <DataStateSkeleton
</> icon={
)}
{meta?.filters && (
<>
{/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon <Icon
icon='heroicons:chart-bar' icon='heroicons:chart-bar'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/> />
</div> }
title='Data Not Yet Available'
description='Please change your filters to get the data.'
/>
</>
)}
</div>
{/* Empty state text */} <div className='flex flex-row w-full items-center gap-4'>
<h3 className='text-gray-900 font-semibold text-base mb-2'> <div className='flex-1 h-full min-w-4'>
Data Not Yet Available <div className='h-28.5 w-4 bg-base-content/4 rounded'></div>
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</>
)}
</div> </div>
<div className='w-full grid grid-cols-1 gap-y-13.25 mb-2'>
{/* Placeholder for chart height */} {[1, 2, 3, 4].map((item) => (
<div className='h-64'></div> <div key={item} className='flex items-center w-full h-4 gap-4'>
<div className='h-4 w-6 bg-base-content/4 rounded'></div>
{/* X-axis skeleton (bottom) */} <div className='h-0.25 w-full bg-base-content/4 rounded'></div>
<div className='flex justify-between pt-4 border-t border-gray-100'> </div>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => (
<div
key={item}
className='h-4 w-8 bg-gray-100 rounded animate-pulse'
></div>
))} ))}
</div> </div>
</div> </div>
{/* X-axis skeleton (bottom) */}
<div className='grid grid-cols-10 gap-15 mt-4 ps-13 sm:ps-26 overflow-x-hidden'>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => (
<div
key={item}
className='h-4 w-9.5 bg-base-content/4 rounded'
></div>
))}
</div>
<div className='flex justify-center pt-4 ps-13 sm:ps-26'>
<div className='h-4 w-28.5 bg-base-content/4 rounded'></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -43,7 +43,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
return ( return (
<> <>
<section className='w-full max-w-7xl pb-16'> <section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -65,7 +65,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
tabs={expenseDetailTabs} tabs={expenseDetailTabs}
variant='lifted' variant='lifted'
className={{ className={{
wrapper: 'max-w-5xl mx-auto mt-4', wrapper: 'mx-auto mt-4',
}} }}
/> />
</section> </section>
@@ -68,7 +68,7 @@ const ExpenseRealizationContent = ({
return ( return (
<div> <div>
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'> <div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
<RequirePermission permissions='lti.expense.update.realization'> <RequirePermission permissions='lti.expense.update.realization'>
<Button <Button
@@ -84,7 +84,7 @@ const ExpenseRealizationContent = ({
</div> </div>
</div> </div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'> <div className='overflow-x-auto w-full mx-auto'>
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<tbody> <tbody>
<tr> <tr>
@@ -179,7 +179,7 @@ const ExpenseRealizationContent = ({
</table> </table>
</div> </div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div className='w-full mt-8 mx-auto'>
<div className='flex flex-row gap-4'> <div className='flex flex-row gap-4'>
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}> <Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
<div className='w-full flex flex-col gap-2'> <div className='w-full flex flex-col gap-2'>
@@ -216,127 +216,141 @@ const ExpenseRealizationContent = ({
</div> </div>
</div> </div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div className='w-full mt-8 mx-auto grid grid-cols-2 gap-4'>
<h2 className='font-bold text-xl text-center'> <div>
Rincian Pengajuan Biaya Operasional <h2 className='font-bold text-xl text-center'>
</h2> Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'> <div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { {initialValues?.kandangs.map(
let expenseGrandTotal = 0; (kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
<div <div
key={kandangExpenseIdx} key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto' className='overflow-x-auto w-full mx-auto'
> >
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<thead> <thead>
<tr> <tr>
<th <th
colSpan={5} colSpan={5}
className='font-bold text-center text-base-content text-lg' className='font-bold text-center text-base-content text-lg'
> >
Biaya {kandangExpense.name} Biaya {kandangExpense.name}
</th> </th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.pengajuans?.map(
(pengajuanItem, pengajuanIdx) => (
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
</tr> </tr>
) <tr>
)} <th>Nonstock</th>
</tbody> <th>Total Kuantitas</th>
<tfoot> <th>Total Biaya</th>
<tr className='border-y'> <th>Catatan</th>
<th colSpan={2} className='text-right'> </tr>
Total Biaya Keseluruhan: </thead>
</th> <tbody>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th> {kandangExpense.pengajuans?.map(
</tr> (pengajuanItem, pengajuanIdx) => (
</tfoot> <tr key={pengajuanIdx}>
</table> <td>{pengajuanItem.nonstock.name}</td>
</div> <td>{pengajuanItem.qty}</td>
); <td>{formatCurrency(pengajuanItem.price)}</td>
})} <td className='w-xs'>
{pengajuanItem.notes ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div> </div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div>
<h2 className='font-bold text-xl text-center'> <h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional Rincian Realisasi Biaya Operasional
</h2> </h2>
<div className='w-full mt-2 flex flex-col gap-4'> <div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { {initialValues?.kandangs.map(
let expenseGrandTotal = 0; (kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
<div <div
key={kandangExpenseIdx} key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto' className='overflow-x-auto w-full mx-auto'
> >
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<thead> <thead>
<tr> <tr>
<th <th
colSpan={5} colSpan={5}
className='font-bold text-center text-base-content text-lg' className='font-bold text-center text-base-content text-lg'
> >
Biaya {kandangExpense.name} Biaya {kandangExpense.name}
</th> </th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.realisasi?.map(
(realisasiItem, realisasiIdx) => (
<tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.price)}</td>
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
</tr> </tr>
) <tr>
)} <th>Nonstock</th>
</tbody> <th>Total Kuantitas</th>
<tfoot> <th>Total Biaya</th>
<tr className='border-y'> <th>Catatan</th>
<th colSpan={2} className='text-right'> </tr>
Total Biaya Keseluruhan: </thead>
</th> <tbody>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th> {kandangExpense.realisasi?.map(
</tr> (realisasiItem, realisasiIdx) => (
</tfoot> <tr key={realisasiIdx}>
</table> <td>{realisasiItem.nonstock.name}</td>
</div> <td>{realisasiItem.qty}</td>
); <td>{formatCurrency(realisasiItem.price)}</td>
})} <td className='w-xs'>
{realisasiItem.notes ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,9 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import useSWR from 'swr';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -57,10 +56,6 @@ const ExpenseRequestContent = ({
const isLatestApprovalRejected = const isLatestApprovalRejected =
initialValues?.latest_approval.action === 'REJECTED'; initialValues?.latest_approval.action === 'REJECTED';
const isLatestApprovalRejectedOrDone =
isLatestApprovalRejected ||
initialValues?.latest_approval.step_number === 6;
const isCurrentApprovalOnHeadArea = const isCurrentApprovalOnHeadArea =
!isLatestApprovalRejected && !isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 1; initialValues?.latest_approval.step_number === 1;
@@ -273,7 +268,7 @@ const ExpenseRequestContent = ({
<> <>
<div> <div>
{initialValues && !isLoadingApprovalHistory && approvalHistory && ( {initialValues && !isLoadingApprovalHistory && approvalHistory && (
<div className='w-full max-w-5xl my-4 mx-auto'> <div className='w-full my-4 mx-auto'>
<ApprovalSteps approvals={approvalHistory} /> <ApprovalSteps approvals={approvalHistory} />
</div> </div>
)} )}
@@ -281,7 +276,7 @@ const ExpenseRequestContent = ({
<div className='w-full mt-4 flex flex-col gap-4'> <div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */} {/* TODO: apply RBAC */}
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && ( {isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'> <RequirePermission permissions='lti.expense.approve.head_area'>
<Button <Button
@@ -414,7 +409,7 @@ const ExpenseRequestContent = ({
</div> </div>
</div> </div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'> <div className='overflow-x-auto w-full mx-auto'>
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<tbody> <tbody>
<tr> <tr>
@@ -608,7 +603,7 @@ const ExpenseRequestContent = ({
</table> </table>
</div> </div>
</div> </div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div className='w-full mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'> <h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional Rincian Pengajuan Biaya Operasional
</h2> </h2>
@@ -654,7 +649,7 @@ const ExpenseRequestContent = ({
<td>{pengajuanItem.qty}</td> <td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td> <td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'> <td className='w-xs'>
{pengajuanItem.note ?? '-'} {pengajuanItem.notes ?? '-'}
</td> </td>
</tr> </tr>
) )
+20 -55
View File
@@ -35,7 +35,6 @@ import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
@@ -44,8 +43,6 @@ import { BaseApiResponse } from '@/types/api/api-general';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
props, props,
approveClickHandler,
rejectClickHandler,
deleteClickHandler, deleteClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; type: 'dropdown' | 'collapse';
@@ -54,17 +51,19 @@ const RowOptionsMenu = ({
rejectClickHandler: () => void; rejectClickHandler: () => void;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = const showEditButton = props.row.original.latest_approval
props.row.original.latest_approval.step_number !== 6 && ? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 || (props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 || props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 || props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4); props.row.original.latest_approval.step_number === 4)
: false;
// TODO: apply RBAC // TODO: apply RBAC
const showRealizationButton = const showRealizationButton = props.row.original.latest_approval
props.row.original.latest_approval.action !== 'REJECTED' && ? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4; props.row.original.latest_approval.step_number === 4
: false;
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
@@ -184,7 +183,6 @@ const ExpensesTable = () => {
undefined undefined
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
@@ -245,23 +243,6 @@ const ExpensesTable = () => {
}); });
}, [expenses, selectedRowIds]); }, [expenses, selectedRowIds]);
const isAllSelectedRowLatestApprovalOnRealization = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnRealization =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 5;
return isCurrentApprovalOnRealization;
});
}, [expenses, selectedRowIds]);
const expensesColumns: ColumnDef<Expense>[] = [ const expensesColumns: ColumnDef<Expense>[] = [
{ {
id: 'select', id: 'select',
@@ -278,6 +259,7 @@ const ExpensesTable = () => {
cell: ({ row }) => { cell: ({ row }) => {
const isCheckboxDisabled = const isCheckboxDisabled =
!row.getCanSelect() || !row.getCanSelect() ||
!row.original.latest_approval ||
row.original.latest_approval.action === 'REJECTED'; row.original.latest_approval.action === 'REJECTED';
return ( return (
@@ -413,6 +395,8 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = ( const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row row
) => { ) => {
if (!row.original.latest_approval) return false;
return ( return (
row.original.latest_approval.action !== 'REJECTED' && row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 6 row.original.latest_approval.step_number !== 6
@@ -584,12 +568,6 @@ const ExpensesTable = () => {
updateFilter('realizationDate', e.target.value); updateFilter('realizationDate', e.target.value);
}; };
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// track sorting // track sorting
useEffect(() => { useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
@@ -692,14 +670,6 @@ const ExpensesTable = () => {
</> </>
)} )}
</div> </div>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div> </div>
<div className='grid grid-cols-12 justify-end gap-2'> <div className='grid grid-cols-12 justify-end gap-2'>
@@ -753,17 +723,12 @@ const ExpensesTable = () => {
}} }}
/> />
<SelectInput <DebouncedTextInput
label='Baris' name='search'
options={ROWS_OPTIONS} placeholder='Cari Biaya Operasional'
value={{ value={tableFilterState.search}
label: String(tableFilterState.pageSize), onChange={searchChangeHandler}
value: tableFilterState.pageSize, className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'col-span-12 max-w-28 justify-self-end',
}}
/> />
</div> </div>
</div> </div>
@@ -19,6 +19,7 @@ import { isResponseSuccess } from '@/lib/api-helper';
interface ExpenseKandangsTableProps { interface ExpenseKandangsTableProps {
locationId?: number; locationId?: number;
type: 'add' | 'edit' | 'detail'; type: 'add' | 'edit' | 'detail';
formType?: 'request' | 'realization';
selectedKandangs: { selectedKandangs: {
id?: number; id?: number;
name?: string; name?: string;
@@ -31,6 +32,7 @@ interface ExpenseKandangsTableProps {
const ExpenseKandangsTable = ({ const ExpenseKandangsTable = ({
type, type,
formType = 'request',
locationId, locationId,
selectedKandangs, selectedKandangs,
onChange, onChange,
@@ -172,69 +174,84 @@ const ExpenseKandangsTable = ({
updateSortingFilter('picSort', picSortFilter); updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]); }, [sorting, updateSortingFilter]);
return ( // Tampilkan tabel jika:
<Card // 1. Mode request pertama kali (type='add' dan formType='request')
className={{ // 2. Atau sudah ada kandang yang dipilih
wrapper: className?.wrapper, const shouldShowTable =
body: 'p-4 shadow', (type === 'add' && formType === 'request') ||
}} (selectedKandangs.length > 0 && selectedKandangs.some((k) => k.id));
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Pilih Kandang</div>
<Icon return (
icon='material-symbols:keyboard-arrow-down' <>
width={24} {shouldShowTable && (
height={24} <Card
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{ className={{
containerClassName: cn({ wrapper: className?.wrapper,
'mb-20': body: 'p-4 shadow',
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 first:flex first:flex-row first:justify-start',
paginationClassName: cn({
hidden:
isResponseSuccess(kandangs) &&
kandangs?.meta?.total_pages === 1,
}),
}} }}
/> >
</Collapse> <Collapse
</Card> open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>
{formType === 'realization'
? 'Kandang yang Direalisasikan'
: 'Pilih Kandang'}
</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 first:flex first:flex-row first:justify-start',
paginationClassName: cn({
hidden:
isResponseSuccess(kandangs) &&
kandangs?.meta?.total_pages === 1,
}),
}}
/>
</Collapse>
</Card>
)}
</>
); );
}; };
@@ -73,7 +73,14 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
realizations: Yup.array() realizations: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(), kandang_id: Yup.number()
.optional()
.test('valid-kandang-id', 'Wajib memilih kandang!', (value) => {
if (value === undefined || value === null || value === 0) {
return true;
}
return value >= 1;
}),
cost_items: Yup.array() cost_items: Yup.array()
.of( .of(
Yup.object({ Yup.object({
@@ -130,7 +137,7 @@ export const getExpenseRealizationFormInitialValues = (
? formatDate(initialValues?.realization_date, 'YYYY-MM-DD') ? formatDate(initialValues?.realization_date, 'YYYY-MM-DD')
: undefined, : undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({ kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id, id: kandang.id,
name: kandang.name, name: kandang.name,
})), })),
supplier: initialValues?.supplier supplier: initialValues?.supplier
@@ -159,7 +166,7 @@ export const getExpenseRealizationFormInitialValues = (
}, },
quantity: realisasiItem.qty, quantity: realisasiItem.qty,
price: realisasiItem.price, price: realisasiItem.price,
notes: realisasiItem.note, notes: realisasiItem.notes,
}; };
}) })
: kandangExpense.pengajuans : kandangExpense.pengajuans
@@ -170,12 +177,12 @@ export const getExpenseRealizationFormInitialValues = (
}, },
quantity: expenseItem.qty, quantity: expenseItem.qty,
price: expenseItem.price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.notes,
})) }))
: []; : [];
return { return {
kandang_id: kandangExpense.kandang_id, kandang_id: kandangExpense.id,
cost_items: costItemsInitialValue, cost_items: costItemsInitialValue,
}; };
}) })
@@ -17,6 +17,7 @@ import DropFileInput from '@/components/input/DropFileInput';
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable'; import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
import ExpenseRealizationKandangDetailExpense from '@/components/pages/expense/form/ExpenseRealizationKandangDetailExpense'; import ExpenseRealizationKandangDetailExpense from '@/components/pages/expense/form/ExpenseRealizationKandangDetailExpense';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { import {
CreateExpenseRealizationPayload, CreateExpenseRealizationPayload,
@@ -35,6 +36,7 @@ import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { ACCEPTED_FILE_TYPE } from '@/config/constant'; import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
interface ExpenseRealizationFormProps { interface ExpenseRealizationFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -99,13 +101,23 @@ const ExpenseRealizationForm = ({
values.realizations.forEach((realization) => { values.realizations.forEach((realization) => {
realization.cost_items.forEach((costItem) => { realization.cost_items.forEach((costItem) => {
const realizationItem = { const realizationItem: {
expense_nonstock_id: number;
qty: number;
price: number;
notes: string;
kandang_id?: number;
} = {
expense_nonstock_id: costItem.nonstock?.value as number, expense_nonstock_id: costItem.nonstock?.value as number,
qty: parseFloat(String(costItem.quantity)) as number, qty: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
}; };
if (realization.kandang_id && realization.kandang_id > 0) {
realizationItem.kandang_id = realization.kandang_id;
}
realizations.push(realizationItem); realizations.push(realizationItem);
}); });
}); });
@@ -132,6 +144,7 @@ const ExpenseRealizationForm = ({
}); });
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
@@ -246,7 +259,7 @@ const ExpenseRealizationForm = ({
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]); }, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
return ( return (
<section className='w-full max-w-5xl'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -263,7 +276,7 @@ const ExpenseRealizationForm = ({
</header> </header>
<form <form
onSubmit={formik.handleSubmit} onSubmit={handleFormSubmit}
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
@@ -294,6 +307,7 @@ const ExpenseRealizationForm = ({
<ExpenseKandangsTable <ExpenseKandangsTable
type='detail' type='detail'
formType='realization'
locationId={formik.values.location?.value} locationId={formik.values.location?.value}
selectedKandangs={formik.values.kandangs ?? []} selectedKandangs={formik.values.kandangs ?? []}
onChange={kandangsChangeHandler} onChange={kandangsChangeHandler}
@@ -372,6 +386,8 @@ const ExpenseRealizationForm = ({
/> />
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
{expenseFormErrorMessage && ( {expenseFormErrorMessage && (
<div role='alert' className='alert alert-error w-full'> <div role='alert' className='alert alert-error w-full'>
<Icon <Icon
@@ -421,7 +437,7 @@ const ExpenseRealizationForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={formik.isSubmitting}
className='px-4' className='px-4'
> >
Submit Submit
@@ -30,7 +30,7 @@ interface ExpenseRealizationKandangDetailExpenseProps {
const ExpenseRealizationKandangDetailExpense: React.FC< const ExpenseRealizationKandangDetailExpense: React.FC<
ExpenseRealizationKandangDetailExpenseProps ExpenseRealizationKandangDetailExpenseProps
> = ({ type, formik, supplierId, location, className }) => { > = ({ formik, supplierId, location, className }) => {
const { const {
setInputValue: setNonstockInputValue, setInputValue: setNonstockInputValue,
options: nonstockOptions, options: nonstockOptions,
@@ -41,22 +41,25 @@ type ExpenseFormSchemaType = {
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> = export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
Yup.object({ Yup.object({
category: Yup.object({ category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), value: Yup.string()
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), .oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
label: Yup.string()
.oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
}) })
.nullable() .nullable()
.optional(), .required('Kategori wajib diisi!')
.typeError('Kategori wajib diisi!'),
location: Yup.object({ location: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).nullable(),
.nullable()
.optional(),
location_id: Yup.number() location_id: Yup.number()
.required('Lokasi wajib diisi!')
.min(1, 'Lokasi wajib diisi!') .min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'), .typeError('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'), transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
@@ -73,9 +76,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).nullable(),
.nullable()
.optional(),
supplier_id: Yup.number() supplier_id: Yup.number()
.required('Vendor wajib diisi!') .required('Vendor wajib diisi!')
@@ -104,9 +105,12 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
.of( .of(
Yup.object({ Yup.object({
nonstock: Yup.object({ nonstock: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required('Nonstock wajib diisi!'),
label: Yup.string().required(), label: Yup.string().required('Nonstock wajib diisi!'),
}).nullable(), })
.nullable()
.required('Nonstock wajib diisi!')
.typeError('Nonstock wajib diisi!'),
nonstock_id: Yup.number() nonstock_id: Yup.number()
.required('Nonstock wajib diisi!') .required('Nonstock wajib diisi!')
.min(1, 'Nonstock wajib diisi!') .min(1, 'Nonstock wajib diisi!')
@@ -204,7 +208,7 @@ export const getExpenseFormInitialValues = (
nonstock_id: expenseItem.nonstock.id, nonstock_id: expenseItem.nonstock.id,
quantity: expenseItem.qty, quantity: expenseItem.qty,
price: expenseItem.price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.notes,
})) }))
: [], : [],
})) }))
@@ -190,30 +190,18 @@ const ExpenseRequestForm = ({
formik.setFieldValue('category', val); formik.setFieldValue('category', val);
}; };
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = useCallback(
formik.setFieldTouched('location', true); (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('location', val); const location = val as OptionType | null;
const locationId = location ? Number(location.value) : 0;
const locationId = Array.isArray(val) ? val[0]?.value : val?.value; formik.setFieldTouched('location', true);
formik.setFieldValue('location_id', locationId); formik.setFieldValue('location', location);
formik.setFieldTouched('location_id', true);
formik.setFieldValue('kandangs', []); formik.setFieldValue('location_id', locationId);
},
// Auto-create expense item for location (without kandang) []
formik.setFieldValue('expense_nonstocks', [ );
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
};
const kandangsChangeHandler = ( const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[] kandangs: { id?: number; name?: string }[]
@@ -268,6 +256,7 @@ const ExpenseRequestForm = ({
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('supplier', true); formik.setFieldTouched('supplier', true);
formik.setFieldTouched('supplier_id', true);
formik.setFieldValue('supplier', val); formik.setFieldValue('supplier', val);
const supplierId = Array.isArray(val) ? val[0]?.value : val?.value; const supplierId = Array.isArray(val) ? val[0]?.value : val?.value;
@@ -360,7 +349,7 @@ const ExpenseRequestForm = ({
return ( return (
<> <>
<section className='w-full max-w-5xl'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -383,23 +372,6 @@ const ExpenseRequestForm = ({
onReset={formik.handleReset} onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='grid grid-cols-12 gap-4'> <div className='grid grid-cols-12 gap-4'>
<SelectInput <SelectInput
label='Kategori' label='Kategori'
@@ -407,6 +379,16 @@ const ExpenseRequestForm = ({
placeholder='Pilih Kategori' placeholder='Pilih Kategori'
value={formik.values.category} value={formik.values.category}
onChange={categoryChangeHandler} onChange={categoryChangeHandler}
isError={
formik.touched.category && Boolean(formik.errors.category)
}
errorMessage={
formik.touched.category && formik.errors.category
? typeof formik.errors.category === 'object'
? 'Kategori wajib diisi!'
: (formik.errors.category as string)
: undefined
}
options={[ options={[
{ {
value: 'BOP', value: 'BOP',
@@ -427,8 +409,13 @@ const ExpenseRequestForm = ({
value={formik.values.location} value={formik.values.location}
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={locationOptions} options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
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' }} className={{ wrapper: 'col-span-12 sm:col-span-4' }}
/> />
@@ -438,6 +425,12 @@ const ExpenseRequestForm = ({
required required
value={formik.values.transaction_date} value={formik.values.transaction_date}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
Boolean(formik.errors.transaction_date)
}
errorMessage={formik.errors.transaction_date as string}
className={{ className={{
wrapper: 'col-span-12 sm:col-span-4', wrapper: 'col-span-12 sm:col-span-4',
}} }}
@@ -460,8 +453,12 @@ const ExpenseRequestForm = ({
value={formik.values.supplier} value={formik.values.supplier}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions} options={supplierOptions}
isLoading={isLoadingVendorOptions}
onInputChange={setVendorInputValue} onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
}
errorMessage={formik.errors.supplier_id as string}
className={{ wrapper: 'col-span-12' }} className={{ wrapper: 'col-span-12' }}
/> />
@@ -543,6 +540,24 @@ const ExpenseRequestForm = ({
/> />
</div> </div>
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
@@ -55,6 +55,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true true
); );
formik.setFieldTouched(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock_id`,
true
);
formik.setFieldValue( formik.setFieldValue(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val val
@@ -96,7 +100,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
}; };
const isExpenseRepeaterInputError = ( const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'price' | 'notes', column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
@@ -105,11 +109,14 @@ const ExpenseRequestKandangDetailExpense: React.FC<
expenseIdx expenseIdx
]?.[column] && ]?.[column] &&
Boolean( Boolean(
formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof formik.errors.expense_nonstocks?.[kandangExpenseIdx] &&
Object && typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx] ===
'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
] instanceof Object && ] &&
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx]
.cost_items?.[expenseIdx] === 'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
]?.[column] ]?.[column]
@@ -117,6 +124,32 @@ const ExpenseRequestKandangDetailExpense: React.FC<
); );
}; };
const getExpenseRepeaterErrorMessage = (
column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
): string => {
const kandangError = formik.errors.expense_nonstocks?.[kandangExpenseIdx];
if (!kandangError || typeof kandangError !== 'object') return '';
if (!('cost_items' in kandangError)) return '';
const costItemsError = kandangError.cost_items?.[expenseIdx];
if (!costItemsError || typeof costItemsError !== 'object') return '';
const fieldError = costItemsError[column as keyof typeof costItemsError];
if (!fieldError) return '';
if (typeof fieldError === 'object' && fieldError !== null) {
return 'Nonstock wajib diisi!';
}
return String(fieldError);
};
return ( return (
<Card <Card
className={{ className={{
@@ -202,10 +235,21 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val val
); );
}} }}
isError={isExpenseRepeaterInputError(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
options={nonstockOptions} options={nonstockOptions}
isLoading={isLoadingNonstockOptions} isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue} onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }} className={{ wrapper: 'min-w-48' }}
isClearable={true}
/> />
</td> </td>
@@ -226,6 +270,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }} className={{ wrapper: 'min-w-24' }}
/> />
</td> </td>
@@ -246,6 +295,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={ inputPrefix={
<span className='text-gray-600 font-medium'> <span className='text-gray-600 font-medium'>
Rp Rp
@@ -271,6 +325,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }} className={{ wrapper: 'min-w-24' }}
/> />
</td> </td>
@@ -447,7 +447,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.note} {pengajuan.notes}
</Text> </Text>
</View> </View>
</View> </View>
@@ -607,7 +607,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.note} {realisasi.notes}
</Text> </Text>
</View> </View>
</View> </View>
+26 -23
View File
@@ -30,11 +30,11 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
}, },
{ {
label: 'Jenis Transaksi', label: 'Jenis Transaksi',
value: finance.transaction_type, value: formatTitleCase(finance.transaction_type.split('_').join(' ')),
}, },
{ {
label: 'Pihak', label: 'Pihak',
value: finance.party.id ? finance.party.name : '-', value: finance.party?.id ? finance.party?.name : '-',
}, },
{ {
label: 'Tanggal', label: 'Tanggal',
@@ -56,25 +56,27 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
}, },
{ {
label: 'Nomor Rekening', label: 'Nomor Rekening',
value: `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`, value: finance.bank?.alias
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`
: '-',
}, },
{ {
label: `Rekening ${formatTitleCase(finance.party.type)}`, label: `Rekening ${formatTitleCase(finance.party?.type)}`,
value: finance.party.account_number, value: finance.party?.account_number,
}, },
{ {
label: 'Nominal', label: 'Nominal',
value: formatCurrency(finance.expense_amount), value: formatCurrency(
}, finance.transaction_type === 'INJECTION'
{ ? finance.nominal
label: 'Sisa', : Math.abs(finance.nominal)
value: formatCurrency(finance.income_amount), ),
}, },
].filter((item) => { ].filter((item) => {
// Hide party account number row if transaction type is INJECTION // Hide party account number row if transaction type is INJECTION
if ( if (
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) && FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
item.label === `Rekening ${formatTitleCase(finance.party.type)}` item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
) { ) {
return false; return false;
} }
@@ -148,18 +150,19 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Card> </Card>
<div className='flex flex-row gap-2 justify-end'> <div className='flex flex-row gap-2 justify-end'>
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && ( {FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
<RequirePermission permissions='lti.finance.payments.update'> finance.party?.type !== 'SUPPLIER' && (
<Button <RequirePermission permissions='lti.finance.payments.update'>
color='warning' <Button
className='min-w-24' color='warning'
href={`/finance/detail/edit?financeId=${finance.id}`} className='min-w-24'
> href={`/finance/detail/edit?financeId=${finance.id}`}
<Icon icon='mdi:pencil-outline' /> >
Edit <Icon icon='mdi:pencil-outline' />
</Button> Edit
</RequirePermission> </Button>
)} </RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && ( {FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
<RequirePermission permissions='lti.finance.initial_balances.update'> <RequirePermission permissions='lti.finance.initial_balances.update'>
<Button <Button
+158 -79
View File
@@ -1,21 +1,23 @@
import { ChangeEventHandler, useMemo, useState } from 'react'; import {
import { CellContext, Row } from '@tanstack/react-table'; ChangeEventHandler,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/dropdown/Dropdown';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Tooltip from '@/components/Tooltip';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance'; import { Finance } from '@/types/api/finance/finance';
@@ -23,7 +25,7 @@ import {
FINANCE_INITIAL_BALANCE_STATUS, FINANCE_INITIAL_BALANCE_STATUS,
FINANCE_INJECTION_STATUS, FINANCE_INJECTION_STATUS,
FINANCE_TRANSACTION_STATUS, FINANCE_TRANSACTION_STATUS,
ROWS_OPTIONS, FINANCE_TRANSACTION_TYPE_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -37,6 +39,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -137,6 +140,9 @@ const RowOptionsMenu = ({
}; };
const FinanceTable = () => { const FinanceTable = () => {
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
const previousPathRef = useRef<string | null>(null);
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -145,10 +151,11 @@ const FinanceTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
transactionType: '', transactionType: '',
bankId: '', bankId: '',
partyType: '', customerId: '',
supplierId: '',
sortBy: '', sortBy: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
@@ -158,7 +165,8 @@ const FinanceTable = () => {
pageSize: 'limit', pageSize: 'limit',
transactionType: 'transaction_type', transactionType: 'transaction_type',
bankId: 'bank_id', bankId: 'bank_id',
partyType: 'party_type', customerId: 'customer_id',
supplierId: 'supplier_id',
sortBy: 'sort_date', sortBy: 'sort_date',
startDate: 'start_date', startDate: 'start_date',
endDate: 'end_date', endDate: 'end_date',
@@ -169,20 +177,27 @@ const FinanceTable = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const deleteModal = useModal(); const deleteModal = useModal();
const [pendingFilters, setPendingFilters] = useState({ const [pendingFilters, setPendingFilters] = useState({
search: '', search: searchValue,
transactionType: '', transactionType: '',
bankId: '', bankId: '',
partyType: '', customerId: '',
supplierId: '',
sortBy: '', sortBy: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
}); });
const [selectedTransactionType, setSelectedTransactionType] = const [selectedTransactionType, setSelectedTransactionType] = useState<
useState<OptionType | null>(null); OptionType | OptionType[] | null
const [selectedBank, setSelectedBank] = useState<OptionType | null>(null); >(null);
const [selectedPartyType, setSelectedPartyType] = useState<OptionType | null>( const [selectedBank, setSelectedBank] = useState<
null OptionType | OptionType[] | null
); >(null);
const [selectedCustomerId, setSelectedCustomerId] = useState<
OptionType | OptionType[] | null
>(null);
const [selectedSupplierId, setSelectedSupplierId] = useState<
OptionType | OptionType[] | null
>(null);
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null); const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null); const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
@@ -197,37 +212,30 @@ const FinanceTable = () => {
FinanceApi.getAllFetcher FinanceApi.getAllFetcher
); );
// ===== Options ===== const {
const transactionTypeOptions = useMemo(() => { options: customerOptions,
return [ isLoadingOptions: customerIsLoadingOptions,
{ label: 'Transfer', value: 'TRANSFER' }, setInputValue: customerInputValue,
{ label: 'Cash', value: 'CASH' }, loadMore: customerLoadMore,
{ label: 'Card', value: 'CARD' }, } = useSelect(CustomerApi.basePath, 'id', 'name');
{ label: 'Cheque', value: 'CHEQUE' }, const {
{ label: 'Saldo', value: 'SALDO' }, options: supplierOptions,
]; isLoadingOptions: supplierIsLoadingOptions,
}, []); setInputValue: supplierInputValue,
const partyTypeOptions = useMemo(() => { loadMore: supplierLoadMore,
return [ } = useSelect(SupplierApi.basePath, 'id', 'name');
{ label: 'Customer', value: 'CUSTOMER' },
{ label: 'Supplier', value: 'SUPPLIER' },
];
}, []);
const sortByOptions = useMemo(() => { const sortByOptions = useMemo(() => {
return [ return [
{ label: 'Tanggal Pembayaran', value: 'payment_date' }, { label: 'Tanggal Pembayaran', value: 'payment_date' },
{ label: 'Tanggal Dibuat', value: 'created_at' }, { label: 'Tanggal Dibuat', value: 'created_at' },
]; ];
}, []); }, []);
const { options: bankOptions, rawData: bankRawData } = useSelect<Bank>( const {
BankApi.basePath, options: bankOptions,
'id', rawData: bankRawData,
'alias', setInputValue: bankInputValue,
'', loadMore: bankLoadMore,
{ } = useSelect<Bank>(BankApi.basePath, 'id', 'alias');
limit: 'limit',
}
);
// ===== Handler ===== // ===== Handler =====
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
@@ -236,24 +244,47 @@ const FinanceTable = () => {
const transactionTypeChangeHandler = ( const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
) => { ) => {
setSelectedTransactionType(val as OptionType); setSelectedTransactionType(val);
setPendingFilters((prev) => ({ setPendingFilters((prev) => ({
...prev, ...prev,
transactionType: val ? ((val as OptionType).value as string) : '', transactionType: val
? Array.isArray(val)
? val.map((item) => item.value).join(',')
: (val.value as string)
: '',
})); }));
}; };
const bankChangeHandler = (val: OptionType | OptionType[] | null) => { const bankChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedBank(val as OptionType); setSelectedBank(val);
setPendingFilters((prev) => ({ setPendingFilters((prev) => ({
...prev, ...prev,
bankId: val ? ((val as OptionType).value as string) : '', bankId: val
? Array.isArray(val)
? val.map((item) => item.value).join(',')
: (val.value as string)
: '',
})); }));
}; };
const partyTypeChangeHandler = (val: OptionType | OptionType[] | null) => { const customerIdChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedPartyType(val as OptionType); setSelectedCustomerId(val);
setPendingFilters((prev) => ({ setPendingFilters((prev) => ({
...prev, ...prev,
partyType: val ? ((val as OptionType).value as string) : '', customerId: val
? Array.isArray(val)
? val.map((item) => item.value).join(',')
: (val.value as string)
: '',
}));
};
const supplierIdChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedSupplierId(val);
setPendingFilters((prev) => ({
...prev,
supplierId: val
? Array.isArray(val)
? val.map((item) => item.value).join(',')
: (val.value as string)
: '',
})); }));
}; };
const sortByChangeHandler = (val: OptionType | OptionType[] | null) => { const sortByChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -275,9 +306,11 @@ const FinanceTable = () => {
}; };
const submitFilterHandler = () => { const submitFilterHandler = () => {
updateFilter('search', pendingFilters.search); updateFilter('search', pendingFilters.search);
setSearchValue(pendingFilters.search);
updateFilter('transactionType', pendingFilters.transactionType); updateFilter('transactionType', pendingFilters.transactionType);
updateFilter('bankId', pendingFilters.bankId); updateFilter('bankId', pendingFilters.bankId);
updateFilter('partyType', pendingFilters.partyType); updateFilter('customerId', pendingFilters.customerId);
updateFilter('supplierId', pendingFilters.supplierId);
updateFilter('sortBy', pendingFilters.sortBy); updateFilter('sortBy', pendingFilters.sortBy);
updateFilter('startDate', pendingFilters.startDate); updateFilter('startDate', pendingFilters.startDate);
updateFilter('endDate', pendingFilters.endDate); updateFilter('endDate', pendingFilters.endDate);
@@ -285,14 +318,16 @@ const FinanceTable = () => {
const resetFilterHandler = () => { const resetFilterHandler = () => {
setSelectedTransactionType(null); setSelectedTransactionType(null);
setSelectedBank(null); setSelectedBank(null);
setSelectedPartyType(null); setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null); setSelectedSortBy(null);
const emptyFilters = { const emptyFilters = {
search: '', search: '',
transactionType: '', transactionType: '',
bankId: '', bankId: '',
partyType: '', customerId: '',
supplierId: '',
sortBy: '', sortBy: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
@@ -300,9 +335,11 @@ const FinanceTable = () => {
setPendingFilters(emptyFilters); setPendingFilters(emptyFilters);
updateFilter('search', ''); updateFilter('search', '');
resetSearchValue();
updateFilter('transactionType', ''); updateFilter('transactionType', '');
updateFilter('bankId', ''); updateFilter('bankId', '');
updateFilter('partyType', ''); updateFilter('customerId', '');
updateFilter('supplierId', '');
updateFilter('sortBy', ''); updateFilter('sortBy', '');
updateFilter('startDate', ''); updateFilter('startDate', '');
updateFilter('endDate', ''); updateFilter('endDate', '');
@@ -344,10 +381,10 @@ const FinanceTable = () => {
}, },
{ {
header: 'Pihak', header: 'Pihak',
accessorFn: (finance: Finance) => finance.party.name, accessorFn: (finance: Finance) => finance.party?.name,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party.id) { if (props.row.original.party?.id) {
return <span>{props.row.original.party.name}</span>; return <span>{props.row.original.party?.name}</span>;
} }
return <span>{'-'}</span>; return <span>{'-'}</span>;
}, },
@@ -368,16 +405,19 @@ const FinanceTable = () => {
{ {
header: 'Bank', header: 'Bank',
accessorFn: (finance: Finance) => accessorFn: (finance: Finance) =>
`${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`, finance.bank
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`
: '-',
}, },
{ {
header: 'Pengeluaran (Rp)', header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) => accessorFn: (finance: Finance) =>
formatCurrency(finance.expense_amount), formatCurrency(Math.abs(finance.expense_amount)),
}, },
{ {
header: 'Pemasukan (Rp)', header: 'Pemasukan (Rp)',
accessorFn: (finance: Finance) => formatCurrency(finance.income_amount), accessorFn: (finance: Finance) =>
formatCurrency(Math.abs(finance.income_amount)),
}, },
{ {
header: 'Aksi', header: 'Aksi',
@@ -422,6 +462,26 @@ const FinanceTable = () => {
}, },
]; ];
}, []); }, []);
useEffect(() => {
// Store current path on mount
previousPathRef.current = window.location.pathname;
return () => {
const currentPath = window.location.pathname;
// if both paths are within /finance module
const isCurrentPathFinance = currentPath.includes('/finance');
const isPreviousPathFinance =
previousPathRef.current?.includes('/finance');
// reset if we outside finance module entirely
if (isPreviousPathFinance && !isCurrentPathFinance) {
resetSearchValue();
}
};
}, [resetSearchValue]);
return ( return (
<section className='size-full p-6 flex flex-col gap-6'> <section className='size-full p-6 flex flex-col gap-6'>
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
@@ -475,47 +535,59 @@ const FinanceTable = () => {
> >
<div className='grid grid-cols-4 gap-6'> <div className='grid grid-cols-4 gap-6'>
<SelectInput <SelectInput
options={transactionTypeOptions} options={FINANCE_TRANSACTION_TYPE_OPTIONS}
label='Jenis Transaksi' label='Jenis Transaksi'
value={selectedTransactionType} value={selectedTransactionType}
onChange={transactionTypeChangeHandler} onChange={transactionTypeChangeHandler}
isClearable isClearable
isMulti
/>
<SelectInput
options={customerOptions}
label={'Customer'}
value={selectedCustomerId}
onChange={customerIdChangeHandler}
onInputChange={customerInputValue}
onMenuScrollToBottom={customerLoadMore}
isLoading={customerIsLoadingOptions}
isClearable
isMulti
/>
<SelectInput
options={supplierOptions}
label={'Supplier'}
value={selectedSupplierId}
onChange={supplierIdChangeHandler}
onInputChange={supplierInputValue}
onMenuScrollToBottom={supplierLoadMore}
isLoading={supplierIsLoadingOptions}
isClearable
isMulti
/> />
<SelectInput <SelectInput
options={ options={
isResponseSuccess(bankRawData) isResponseSuccess(bankRawData)
? bankOptions.map((bank) => ({ ? bankOptions.map((bank) => ({
label: label:
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.alias + ?.alias +
' - ' + ' - ' +
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.account_number + ?.account_number +
' - ' + ' - ' +
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.owner, ?.owner,
value: bank.value, value: bank?.value,
})) }))
: [] : []
} }
label='Bank' label='Bank'
value={selectedBank} value={selectedBank}
onChange={bankChangeHandler} onChange={bankChangeHandler}
onInputChange={bankInputValue}
onMenuScrollToBottom={bankLoadMore}
isClearable isClearable
/> isMulti
<SelectInput
options={partyTypeOptions}
label='Pihak'
value={selectedPartyType}
onChange={partyTypeChangeHandler}
isClearable
/>
<DebouncedTextInput
name='search'
label='Cari'
placeholder='Cari'
value={pendingFilters.search}
onChange={searchChangeHandler}
/> />
<SelectInput <SelectInput
options={sortByOptions} options={sortByOptions}
@@ -536,6 +608,13 @@ const FinanceTable = () => {
value={pendingFilters.endDate} value={pendingFilters.endDate}
onChange={endDateChangeHandler} onChange={endDateChangeHandler}
/> />
<DebouncedTextInput
name='search'
label='Cari'
placeholder='Cari'
value={pendingFilters.search}
onChange={searchChangeHandler}
/>
</div> </div>
</Card> </Card>
<Table<Finance> <Table<Finance>
@@ -32,8 +32,10 @@ import {
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
interface FormFinanceAddProps { interface FormFinanceAddProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -51,18 +53,19 @@ const FormFinanceAdd = ({
initialValues, initialValues,
}: FormFinanceAddProps) => { }: FormFinanceAddProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): FinanceFormValues => { const formikInitialValues = useMemo((): FinanceFormValues => {
return { return {
party_type_option: party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find( FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type (option) => option.value === initialValues?.party?.type
) || null, ) || null,
party_id_option: initialValues?.party party_id_option: initialValues?.party
? { ? {
label: initialValues?.party.name || '', label: initialValues?.party?.name || '',
value: initialValues?.party.id || 0, value: initialValues?.party?.id || 0,
} }
: null, : null,
payment_date: initialValues?.payment_date || '', payment_date: initialValues?.payment_date || '',
@@ -72,11 +75,11 @@ const FormFinanceAdd = ({
) || null, ) || null,
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues?.bank?.name,
value: initialValues.bank.id, value: initialValues?.bank?.id,
} }
: null, : null,
party_account_number: initialValues?.party.account_number || '', party_account_number: initialValues?.party?.account_number || '',
reference_number: initialValues?.reference_number || '', reference_number: initialValues?.reference_number || '',
nominal: initialValues?.nominal.toString() || '', nominal: initialValues?.nominal.toString() || '',
notes: initialValues?.notes || '', notes: initialValues?.notes || '',
@@ -113,20 +116,22 @@ const FormFinanceAdd = ({
options: partyOptions, options: partyOptions,
isLoadingOptions: isLoadingPartyOptions, isLoadingOptions: isLoadingPartyOptions,
rawData: partyRawData, rawData: partyRawData,
setInputValue: setPartyInputValue,
loadMore: loadMorePartyOptions,
} = useSelect<PartyCommonProps>( } = useSelect<PartyCommonProps>(
formik.values.party_type_option?.value === 'CUSTOMER' formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath ? CustomerApi.basePath
: SupplierApi.basePath, : SupplierApi.basePath,
'id', 'id',
'name', 'name'
'',
{ limit: 'limit' }
); );
const { const {
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -151,6 +156,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -166,6 +172,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -205,6 +212,7 @@ const FormFinanceAdd = ({
? formik.errors.party_type_option ? formik.errors.party_type_option
: '' : ''
} }
isDisabled={type === 'edit'}
required required
isClearable isClearable
/> />
@@ -219,6 +227,8 @@ const FormFinanceAdd = ({
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`} placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`}
options={partyOptions} options={partyOptions}
value={formik.values.party_id_option} value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_id_option', value); formik.setFieldValue('party_id_option', value);
if (isResponseSuccess(partyRawData) && value) { if (isResponseSuccess(partyRawData) && value) {
@@ -241,7 +251,11 @@ const FormFinanceAdd = ({
} }
required required
isClearable isClearable
isDisabled={!formik.values.party_type_option?.value} isDisabled={
!formik.values.party_type_option?.value ||
(type === 'edit' &&
formik.values.party_type_option.value == 'SUPPLIER')
}
/> />
<DateInput <DateInput
label='Tanggal' label='Tanggal'
@@ -304,6 +318,8 @@ const FormFinanceAdd = ({
: [] : []
} }
value={formik.values.bank_id_option} value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('bank_id_option', value); formik.setFieldValue('bank_id_option', value);
}} }}
@@ -389,6 +405,15 @@ const FormFinanceAdd = ({
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -402,7 +427,7 @@ const FormFinanceAdd = ({
<Button <Button
type='submit' type='submit'
className='w-min-24' className='w-min-24'
disabled={formik.isSubmitting || !formik.isValid} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -27,13 +27,7 @@ export const InitialBalanceFormSchema = Yup.object().shape({
'Pihak wajib diisi', 'Pihak wajib diisi',
(value) => value !== null && value !== undefined (value) => value !== null && value !== undefined
), ),
bank_id_option: Yup.mixed() bank_id_option: Yup.mixed().nullable(),
.nullable()
.test(
'is-valid-option',
'Bank wajib diisi',
(value) => value !== null && value !== undefined
),
reference_number: Yup.string().required('Nomor referensi wajib diisi'), reference_number: Yup.string().required('Nomor referensi wajib diisi'),
initial_balance_type_option: Yup.mixed() initial_balance_type_option: Yup.mixed()
.nullable() .nullable()
@@ -29,8 +29,9 @@ import { Bank } from '@/types/api/master-data/bank';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
interface FormFinanceAddInitialBalanceProps { interface FormFinanceAddInitialBalanceProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -42,6 +43,7 @@ const FormFinanceAddInitialBalance = ({
initialValues, initialValues,
}: FormFinanceAddInitialBalanceProps) => { }: FormFinanceAddInitialBalanceProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): InitialBalanceFormValues => { const formikInitialValues = useMemo((): InitialBalanceFormValues => {
@@ -53,18 +55,18 @@ const FormFinanceAddInitialBalance = ({
return { return {
party_type_option: party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find( FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type (option) => option.value === initialValues?.party?.type
) || null, ) || null,
party_id_option: initialValues?.party party_id_option: initialValues?.party
? { ? {
label: initialValues.party.name, label: initialValues.party?.name,
value: initialValues.party.id, value: initialValues.party?.id,
} }
: null, : null,
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues.bank?.name,
value: initialValues.bank.id, value: initialValues.bank?.id,
} }
: null, : null,
reference_number: initialValues?.reference_number || '', reference_number: initialValues?.reference_number || '',
@@ -104,21 +106,25 @@ const FormFinanceAddInitialBalance = ({
}); });
// ===== Options ===== // ===== Options =====
const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } = const {
useSelect( options: partyOptions,
formik.values.party_type_option?.value === 'CUSTOMER' isLoadingOptions: isLoadingPartyOptions,
? CustomerApi.basePath setInputValue: setPartyInputValue,
: SupplierApi.basePath, loadMore: loadMorePartyOptions,
'id', } = useSelect(
'name', formik.values.party_type_option?.value === 'CUSTOMER'
'', ? CustomerApi.basePath
{ limit: 'limit' } : SupplierApi.basePath,
); 'id',
'name'
);
const { const {
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -143,6 +149,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -162,6 +169,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -189,6 +197,8 @@ const FormFinanceAddInitialBalance = ({
placeholder='Pilih jenis pihak' placeholder='Pilih jenis pihak'
options={FINANCE_PARTY_TYPE_OPTIONS} options={FINANCE_PARTY_TYPE_OPTIONS}
value={formik.values.party_type_option} value={formik.values.party_type_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_type_option', value); formik.setFieldValue('party_type_option', value);
formik.setFieldValue('party_id_option', null); formik.setFieldValue('party_id_option', null);
@@ -205,6 +215,7 @@ const FormFinanceAddInitialBalance = ({
: '' : ''
} }
required required
isDisabled={type === 'edit'}
isClearable isClearable
/> />
<SelectInput <SelectInput
@@ -218,6 +229,8 @@ const FormFinanceAddInitialBalance = ({
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`} placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`}
options={partyOptions} options={partyOptions}
value={formik.values.party_id_option} value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_id_option', value); formik.setFieldValue('party_id_option', value);
}} }}
@@ -232,7 +245,11 @@ const FormFinanceAddInitialBalance = ({
} }
required required
isClearable isClearable
isDisabled={!formik.values.party_type_option?.value} isDisabled={
!formik.values.party_type_option?.value ||
(type === 'edit' &&
formik.values.party_type_option?.value == 'SUPPLIER')
}
/> />
<SelectInput <SelectInput
label='Bank' label='Bank'
@@ -269,7 +286,6 @@ const FormFinanceAddInitialBalance = ({
? formik.errors.bank_id_option ? formik.errors.bank_id_option
: '' : ''
} }
required
isClearable isClearable
/> />
<TextInput <TextInput
@@ -311,6 +327,7 @@ const FormFinanceAddInitialBalance = ({
} }
required required
isClearable isClearable
isDisabled={type == 'edit'}
/> />
<NumberInput <NumberInput
label='Nominal' label='Nominal'
@@ -354,7 +371,18 @@ const FormFinanceAddInitialBalance = ({
} }
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -368,7 +396,7 @@ const FormFinanceAddInitialBalance = ({
<Button <Button
type='submit' type='submit'
className='w-min-24' className='w-min-24'
disabled={formik.isSubmitting || !formik.isValid} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -5,6 +5,7 @@ import * as Yup from 'yup';
export type InjectionFormValues = { export type InjectionFormValues = {
bank_id_option: OptionType | null; bank_id_option: OptionType | null;
adjustment_date: string; adjustment_date: string;
injection_type?: OptionType | null | undefined;
nominal: string; nominal: string;
note: string; note: string;
}; };
@@ -18,6 +19,7 @@ export const InjectionFormSchema = Yup.object<InjectionFormValues>({
(value) => value !== null && value !== undefined (value) => value !== null && value !== undefined
), ),
adjustment_date: Yup.string().required('Tanggal penyesuaian wajib diisi'), adjustment_date: Yup.string().required('Tanggal penyesuaian wajib diisi'),
injection_type: Yup.mixed().nullable().required('Tipe injeksi wajib diisi'),
nominal: Yup.string().required('Nominal wajib diisi'), nominal: Yup.string().required('Nominal wajib diisi'),
note: Yup.string().required('Catatan wajib diisi'), note: Yup.string().required('Catatan wajib diisi'),
}); });
@@ -24,8 +24,14 @@ import {
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
import {
FINANCE_INJECTION_STATUS,
FINANCE_INJECTION_TYPE_OPTIONS,
} from '@/config/constant';
interface FormFinanceInjectionProps { interface FormFinanceInjectionProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -37,17 +43,23 @@ const FormFinanceInjection = ({
initialValues, initialValues,
}: FormFinanceInjectionProps) => { }: FormFinanceInjectionProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): InjectionFormValues => { const formikInitialValues = useMemo((): InjectionFormValues => {
return { return {
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues.bank?.name,
value: initialValues.bank.id, value: initialValues.bank?.id,
} }
: null, : null,
adjustment_date: initialValues?.payment_date || '', adjustment_date: initialValues?.payment_date || '',
injection_type: initialValues?.nominal
? initialValues.nominal < 0
? FINANCE_INJECTION_TYPE_OPTIONS[1]
: FINANCE_INJECTION_TYPE_OPTIONS[0]
: FINANCE_INJECTION_TYPE_OPTIONS[0],
nominal: initialValues?.nominal?.toString() || '', nominal: initialValues?.nominal?.toString() || '',
note: initialValues?.notes || '', note: initialValues?.notes || '',
}; };
@@ -80,7 +92,9 @@ const FormFinanceInjection = ({
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -89,7 +103,10 @@ const FormFinanceInjection = ({
return { return {
bank_id: Number(values.bank_id_option?.value) || 0, bank_id: Number(values.bank_id_option?.value) || 0,
adjustment_date: formatDate(values.adjustment_date, 'YYYY-MM-DD'), adjustment_date: formatDate(values.adjustment_date, 'YYYY-MM-DD'),
nominal: Number(values.nominal.replace(/\D/g, '')) || 0, nominal:
values.injection_type?.value == 'POSITIVE'
? Math.abs(Number(values.nominal))
: -Math.abs(Number(values.nominal)),
notes: values.note, notes: values.note,
}; };
}; };
@@ -101,6 +118,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -117,6 +135,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -162,6 +181,8 @@ const FormFinanceInjection = ({
: [] : []
} }
value={formik.values.bank_id_option} value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('bank_id_option', value); formik.setFieldValue('bank_id_option', value);
}} }}
@@ -194,6 +215,24 @@ const FormFinanceInjection = ({
} }
required required
/> />
<SelectInput
label='Tipe Injeksi'
placeholder='Pilih tipe injeksi'
options={FINANCE_INJECTION_TYPE_OPTIONS}
value={formik.values.injection_type}
onChange={(value) => {
formik.setFieldValue('injection_type', value);
}}
isError={Boolean(
formik.touched.injection_type && formik.errors.injection_type
)}
errorMessage={
formik.touched.injection_type && formik.errors.injection_type
? formik.errors.injection_type
: ''
}
required
/>
<NumberInput <NumberInput
label='Nominal' label='Nominal'
placeholder='Masukkan nominal' placeholder='Masukkan nominal'
@@ -207,8 +246,17 @@ const FormFinanceInjection = ({
? formik.errors.nominal ? formik.errors.nominal
: '' : ''
} }
allowNegative={true} allowNegative={false}
required required
startAdornment={
formik.values.injection_type?.value === 'POSITIVE' ? (
<Icon icon='mdi:plus' />
) : formik.values.injection_type?.value === 'NEGATIVE' ? (
<Icon icon='mdi:minus' />
) : (
''
)
}
/> />
<TextArea <TextArea
label='Catatan' label='Catatan'
@@ -226,6 +274,15 @@ const FormFinanceInjection = ({
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -239,7 +296,7 @@ const FormFinanceInjection = ({
<Button <Button
type='submit' type='submit'
className='w-min-24' className='w-min-24'
disabled={formik.isSubmitting || !formik.isValid} disabled={formik.isSubmitting}
> >
Submit Submit
</Button> </Button>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
@@ -22,12 +22,18 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
InventoryAdjustmentFormErrorMessage, InventoryAdjustmentFormErrorMessage,
setInventoryAdjustmentFormErrorMessage, setInventoryAdjustmentFormErrorMessage,
] = useState(''); ] = useState('');
const [selectedProductCategories, setSelectedProductCategories] =
useState('');
const [disabledProduct, setDisabledProduct] = useState(true); const [disabledProduct, setDisabledProduct] = useState(true);
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok'); const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
// Submit Handler // Submit Handler
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
}); });
// Fetch Data // Fetch Data
const productCategoriesUrl = `${ const {
ProductCategoryApi.basePath setInputValue: setProductCategoryInputValue,
}?${new URLSearchParams({ options: productCategoryOptions,
search: '', isLoadingOptions: isLoadingProductCategoryOptions,
}).toString()}`; loadMore: loadMoreProductCategories,
const { data: productCategories, isLoading: isLoadingProductCategories } = } = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setProductInputValue,
product_category_id: selectedProductCategories, options: productOptions,
}).toString()}`; isLoadingOptions: isLoadingProductOptions,
const { data: products, isLoading: isLoadingProducts } = useSWR( loadMore: loadMoreProducts,
productUrl, } = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
ProductApi.getAllFetcher product_category_id: formik.values.product_category_id
); ? String(formik.values.product_category_id)
: '',
});
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setWarehouseInputValue,
limit: '100', options: warehouseOptions,
}).toString()}`; isLoadingOptions: isLoadingWarehouseOptions,
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( loadMore: loadMoreWarehouses,
warehouseUrl, } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
WarehouseApi.getAllFetcher
);
// Map Data to Options
const optionsProductCategory = isResponseSuccess(productCategories)
? productCategories?.data.map((productCategory) => ({
value: productCategory.id,
label: productCategory.name,
}))
: [];
const optionsWarehouse = isResponseSuccess(warehouses)
? warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
}))
: [];
// Options Handler // Options Handler
const productCategoryChangeHandler = ( const productCategoryChangeHandler = (
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null; const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled); setDisabledProduct(disabled);
formik.setFieldValue('product_id', 0); formik.setFieldValue('product_id', 0);
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
// Effect // Effect
useEffect(() => { useEffect(() => {
if (initialValues?.product_warehouse?.product?.id) { if (initialValues?.product_warehouse?.product?.id) {
setSelectedProductCategories(
String(initialValues.product_warehouse.product.id)
);
setDisabledProduct(false); setDisabledProduct(false);
formik.setFieldValue( formik.setFieldValue(
'product_id', 'product_id',
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
); );
formik.setFieldValue('note', initialValues.note); formik.setFieldValue('note', initialValues.note);
} }
}, [ }, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues); formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
useEffect(() => {
if (isResponseSuccess(products)) {
const options = products.data.map((p) => ({
value: p.id,
label: p.name,
}));
setOptionsProduct(options);
}
}, [products]);
// Utils Function // Utils Function
const formatNumber = (value: string) => { const formatNumber = (value: string) => {
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
label='Kategori Produk' label='Kategori Produk'
value={formik.values.product_category as OptionType} value={formik.values.product_category as OptionType}
onChange={productCategoryChangeHandler} onChange={productCategoryChangeHandler}
onInputChange={setSelectedProductCategories} onInputChange={setProductCategoryInputValue}
options={optionsProductCategory} options={productCategoryOptions}
isLoading={isLoadingProductCategories} onMenuScrollToBottom={loadMoreProductCategories}
isLoading={isLoadingProductCategoryOptions}
isError={ isError={
formik.touched.product_category && formik.touched.product_category &&
Boolean(formik.errors.product_category) Boolean(formik.errors.product_category)
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
label='Produk' label='Produk'
value={formik.values.product as OptionType} value={formik.values.product as OptionType}
onChange={productChangeHandler} onChange={productChangeHandler}
options={optionsProduct} onInputChange={setProductInputValue}
isLoading={isLoadingProducts} options={productOptions}
onMenuScrollToBottom={loadMoreProducts}
isLoading={isLoadingProductOptions}
isError={formik.touched.product && Boolean(formik.errors.product)} isError={formik.touched.product && Boolean(formik.errors.product)}
errorMessage={formik.errors.product as string} errorMessage={formik.errors.product as string}
isDisabled={type === 'detail' || disabledProduct} isDisabled={type === 'detail' || disabledProduct}
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
label='Warehouse' label='Warehouse'
value={formik.values.warehouse as OptionType} value={formik.values.warehouse as OptionType}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
options={optionsWarehouse} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouses} options={warehouseOptions}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouseOptions}
isError={ isError={
formik.touched.warehouse && Boolean(formik.errors.warehouse) formik.touched.warehouse && Boolean(formik.errors.warehouse)
} }
@@ -27,17 +27,17 @@ type MovementFormSchemaType = {
product_qty: number | string; product_qty: number | string;
}[]; }[];
deliveries: { deliveries: {
delivery_cost?: number | string; delivery_cost?: number | string | null;
delivery_cost_per_item?: number | string; delivery_cost_per_item?: number | string | null;
document?: File | MovementDocument | null; document?: File | MovementDocument | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name?: string | null;
vehicle_plate: string; vehicle_plate?: string | null;
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
supplier_id: number; supplier_id?: number | null;
products: { products: {
product?: { product?: {
value: number; value: number;
@@ -59,17 +59,17 @@ export type ProductSchema = {
}; };
export type DeliverySchema = { export type DeliverySchema = {
delivery_cost?: number | string; delivery_cost?: number | string | null;
delivery_cost_per_item?: number | string; delivery_cost_per_item?: number | string | null;
document?: File | MovementDocument | null; document?: File | MovementDocument | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name?: string | null;
vehicle_plate: string; vehicle_plate?: string | null;
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
supplier_id: number; supplier_id?: number | null;
products: { products: {
product?: { product?: {
value: number; value: number;
@@ -110,48 +110,82 @@ const DeliveryProductObjectSchema = Yup.object({
.typeError('Qty harus berupa angka!'), .typeError('Qty harus berupa angka!'),
}); });
const DeliveryDocumentSchema = Yup.mixed<File | MovementDocument>()
.nullable()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value): boolean => {
if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true;
});
const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
delivery_cost: Yup.number() delivery_cost: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .transform((value) =>
.min(1, 'Biaya minimal 1!') isNaN(value) || value === '' || value === null ? undefined : value
.typeError('Biaya harus berupa angka!') )
.test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) { .when('supplier_id', {
const { delivery_cost_per_item } = this.parent; is: (supplier_id: number | null | undefined) =>
return ( supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
(value !== undefined && value > 0) || then: (schema) =>
(delivery_cost_per_item !== undefined && delivery_cost_per_item > 0) schema
); .required('Biaya pengiriman wajib diisi!')
.min(1, 'Biaya minimal 1!')
.typeError('Biaya harus berupa angka!'),
otherwise: (schema) =>
schema
.optional()
.nullable()
.min(1, 'Biaya minimal 1!')
.typeError('Biaya harus berupa angka!'),
}), }),
delivery_cost_per_item: Yup.number() delivery_cost_per_item: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .transform((value) =>
.min(1, 'Biaya per item minimal 1!') isNaN(value) || value === '' || value === null ? undefined : value
.typeError('Biaya per item harus berupa angka!') )
.test('one-of-cost-fields', 'Wajib diisi salah satu!', function (value) { .when('supplier_id', {
const { delivery_cost } = this.parent; is: (supplier_id: number | null | undefined) =>
return ( supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
(value !== undefined && value > 0) || then: (schema) =>
(delivery_cost !== undefined && delivery_cost > 0) schema
); .required('Biaya per item wajib diisi!')
.min(1, 'Biaya per item minimal 1!')
.typeError('Biaya per item harus berupa angka!'),
otherwise: (schema) =>
schema
.optional()
.nullable()
.min(1, 'Biaya per item minimal 1!')
.typeError('Biaya per item harus berupa angka!'),
}), }),
document_path: Yup.string().nullable().optional(), document_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>() document: DeliveryDocumentSchema,
.nullable() driver_name: Yup.string().when('supplier_id', {
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => { is: (supplier_id: number | null | undefined) =>
if (!value) return true; supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
if (value instanceof File) return value.size <= 5 * 1024 * 1024; then: (schema) =>
return true; schema
}), .required('Nama sopir wajib diisi!')
driver_name: Yup.string().required('Nama sopir wajib diisi!'), .min(1, 'Nama sopir wajib diisi!'),
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), otherwise: (schema) => schema.optional().nullable(),
}),
vehicle_plate: Yup.string().when('supplier_id', {
is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Plat nomor wajib diisi!')
.min(1, 'Plat nomor wajib diisi!'),
otherwise: (schema) => schema.optional().nullable(),
}),
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
supplier_id: Yup.number() supplier_id: Yup.number()
.required('Supplier wajib diisi!') .optional()
.min(1, 'Supplier wajib diisi!') .nullable()
.typeError('Supplier wajib diisi!'), .typeError('Supplier harus berupa angka!'),
products: Yup.array() products: Yup.array()
.of(DeliveryProductObjectSchema) .of(DeliveryProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!') .min(1, 'Minimal harus ada 1 produk!')
@@ -277,12 +311,12 @@ export const getMovementFormInitialValues = (
}) ?? [], }) ?? [],
})) ?? [ })) ?? [
{ {
delivery_cost: undefined, delivery_cost: null,
delivery_cost_per_item: undefined, delivery_cost_per_item: null,
document: null, document: null,
document_path: null, document_path: null,
driver_name: '', driver_name: null,
vehicle_plate: '', vehicle_plate: null,
supplier: null, supplier: null,
supplier_id: 0, supplier_id: 0,
products: [ products: [
File diff suppressed because it is too large Load Diff
@@ -91,7 +91,7 @@ const InventoryProductDetail = ({
<td>:</td> <td>:</td>
<td> <td>
{inventoryProduct?.tax {inventoryProduct?.tax
? formatCurrency(inventoryProduct?.tax) ? formatNumber(inventoryProduct?.tax) + '%'
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -16,7 +16,7 @@ import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar'; import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { import {
MarketingApi, MarketingApi,
SalesOrderApi, SalesOrderApi,
@@ -33,6 +33,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { CustomerApi, ProductApi } from '@/services/api/master-data'; import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line'; import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import Badge from '@/components/Badge';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -184,12 +185,16 @@ const MarketingTable = () => {
const { const {
options: productsOptions, options: productsOptions,
isLoadingOptions: isLoadingProductsOptions, isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect(ProductApi.basePath, 'id', 'name', '', { } = useSelect(ProductApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
const { const {
options: customersOptions, options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions, isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', { } = useSelect(CustomerApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
@@ -400,6 +405,8 @@ const MarketingTable = () => {
.join(',') || '' .join(',') || ''
) )
} }
onInputChange={setProductsInputValue}
onMenuScrollToBottom={loadMoreProducts}
isMulti isMulti
/> />
{/* select status */} {/* select status */}
@@ -444,6 +451,8 @@ const MarketingTable = () => {
(value as OptionType)?.value.toString() || '' (value as OptionType)?.value.toString() || ''
) )
} }
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/> />
</TableRowSizeSelector> </TableRowSizeSelector>
</div> </div>
@@ -512,8 +521,53 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'latest_approval.step_name', accessorKey: 'approval.step_name',
header: 'Status', header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start whitespace-nowrap',
}}
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
);
},
}, },
{ {
accessorKey: 'customer.name', accessorKey: 'customer.name',
@@ -16,6 +16,7 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
formatNumber, formatNumber,
formatTitleCase,
formatVechicleNumber, formatVechicleNumber,
} from '@/lib/helper'; } from '@/lib/helper';
import { import {
@@ -34,6 +35,7 @@ import toast from 'react-hot-toast';
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport'; import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport'; import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import Badge from '@/components/Badge';
const MarketingDetail = ({ const MarketingDetail = ({
initialValues, initialValues,
@@ -87,7 +89,6 @@ const MarketingDetail = ({
deleteModal.closeModal(); deleteModal.closeModal();
router.push('/marketing'); router.push('/marketing');
toast.success(res?.message as string); toast.success(res?.message as string);
refresh?.();
setIsLoading(false); setIsLoading(false);
}; };
@@ -121,6 +122,10 @@ const MarketingDetail = ({
); );
}; };
const approval = initialValues?.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return ( return (
<> <>
<div className='flex flex-col w-full gap-4'> <div className='flex flex-col w-full gap-4'>
@@ -230,7 +235,46 @@ const MarketingDetail = ({
<tr> <tr>
<td className='font-semibold'>Status</td> <td className='font-semibold'>Status</td>
<td>:</td> <td>:</td>
<td>{initialValues?.latest_approval?.step_name}</td> <td>
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-fit flex flex-row justify-start whitespace-nowrap',
}}
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
</td>
</tr> </tr>
<tr> <tr>
<td className='font-semibold'>Tanggal Penjualan</td> <td className='font-semibold'>Tanggal Penjualan</td>
@@ -11,6 +11,13 @@ import {
type MarketingSchemaType = { type MarketingSchemaType = {
customer_id: number | undefined; customer_id: number | undefined;
sales_person_id: number | undefined; sales_person_id: number | undefined;
sales_person:
| {
value: number;
label: string;
}
| undefined
| null;
customer: customer:
| { | {
value: number; value: number;
@@ -33,7 +40,11 @@ type DeliveryOrderSchemaType = {
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> = export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({ Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'), customer_id: Yup.number().required('Customer wajib diisi!'),
sales_person_id: Yup.number().required('Sales Person wajib diisi!'), sales_person_id: Yup.number().required('Sales wajib diisi!'),
sales_person: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
customer: Yup.object({ customer: Yup.object({
value: Yup.number().required(), value: Yup.number().required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -50,6 +50,8 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { CreatedUser } from '@/types/api/api-general';
import { UserApi } from '@/services/api/user';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -244,7 +246,15 @@ const MarketingForm = ({
const { const {
options: customerOptions, options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions, isLoadingOptions: isLoadingCustomerOptions,
setInputValue: setInputCustomerValue,
loadMore: loadMoreCustomer,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name'); } = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const {
options: salesOptions,
isLoadingOptions: isLoadingSalesOptions,
setInputValue: setInputSalesValue,
loadMore: loadMoreSales,
} = useSelect<CreatedUser>(UserApi.basePath, 'id', 'name');
// ================== SETUP FORMIK ================== // ================== SETUP FORMIK ==================
const formikInitialValues = useMemo< const formikInitialValues = useMemo<
@@ -255,6 +265,12 @@ const MarketingForm = ({
notes: initialValues?.notes || undefined, notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined, customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || 1, sales_person_id: initialValues?.sales_person?.id || 1,
sales_person: initialValues?.sales_person
? {
value: initialValues.sales_person.id,
label: initialValues.sales_person.name,
}
: null,
customer: initialValues?.customer customer: initialValues?.customer
? { ? {
value: initialValues.customer.id, value: initialValues.customer.id,
@@ -345,6 +361,8 @@ const MarketingForm = ({
}, },
}); });
const memoSalesOrder = formik.values.sales_order;
// ================== FORM REPEATER HANDLER ================== // ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => { const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true); setIsLoading(true);
@@ -443,18 +461,37 @@ const MarketingForm = ({
}, },
[] []
); );
const handleChangeSalesPerson = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('sales_person_id', (val as OptionType)?.value);
formik.setFieldValue('sales_person', val as OptionType);
},
[]
);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
deleteModal.openModal(); deleteModal.openModal();
}, [deleteModal]); }, [deleteModal]);
// ================== SALES ORDER HANDLER ================== // ================== SALES ORDER HANDLER ==================
const handleDeleteSO = useCallback((id: number) => { const handleDeleteSO = useCallback(
const currentProducts = formik.values.sales_order; (id: number) => {
formik.setFieldValue( const currentProducts = formik.values.sales_order;
'sales_order', formik.setFieldValue(
currentProducts.filter((p) => p.id != id) 'sales_order',
); currentProducts.filter((p) => p.id != id)
}, []); );
},
[memoSalesOrder]
);
const handleEditSO = useCallback(
(id: number) => {
const currentProducts = formik.values.sales_order;
const selectedProduct = currentProducts.find((p) => p.id == id);
setSelectedMarketingProduct(selectedProduct ?? null);
addSOModal.openModal();
},
[memoSalesOrder]
);
const handleBulkDeleteSO = useCallback(() => { const handleBulkDeleteSO = useCallback(() => {
const currentProducts = formik.values.sales_order; const currentProducts = formik.values.sales_order;
formik.setFieldValue( formik.setFieldValue(
@@ -464,13 +501,13 @@ const MarketingForm = ({
) )
); );
setRowSOSelection({}); setRowSOSelection({});
}, [selectedRowSOIds]); }, [selectedRowSOIds, memoSalesOrder]);
const handleAddSOClick = useCallback(() => { const handleAddSOClick = useCallback(() => {
setSelectedMarketingProduct(null); setSelectedMarketingProduct(null);
addSOModal.openModal(); addSOModal.openModal();
}, [addSOModal]); }, [addSOModal]);
const handleAddSubmitSO = useCallback( const handleAddSubmitSO = useCallback(
async (values: SalesOrderProductFormValues) => { async (values: SalesOrderProductFormValues, id?: number) => {
const currentProducts = formik.values.sales_order; const currentProducts = formik.values.sales_order;
const newValues = { const newValues = {
@@ -478,18 +515,12 @@ const MarketingForm = ({
id: values.id ?? Date.now(), id: values.id ?? Date.now(),
}; };
const existingIndex = currentProducts.findIndex(
(item) =>
item.kandang_id === newValues.kandang_id &&
item.product_warehouse_id === newValues.product_warehouse_id
);
let updatedProducts = []; let updatedProducts = [];
if (existingIndex !== -1) { if (id) {
// Overwrite // Overwrite
updatedProducts = currentProducts.map((item, index) => updatedProducts = currentProducts.map((item) =>
index === existingIndex ? newValues : item item.id === id ? newValues : item
); );
} else { } else {
// Add new item // Add new item
@@ -500,7 +531,7 @@ const MarketingForm = ({
addSOModal.closeModal(); addSOModal.closeModal();
}, },
[addSOModal] [addSOModal, memoSalesOrder]
); );
// ================== DELIVERY ORDER HANDLER ================== // ================== DELIVERY ORDER HANDLER ==================
@@ -545,8 +576,30 @@ const MarketingForm = ({
}, },
[addDOModal] [addDOModal]
); );
const handleDeleteDO = useCallback(
const memoSalesOrder = formik.values.sales_order; async (id: number) => {
setDeliveryOrderValues((prev) =>
prev.map((product) =>
product.id === id
? {
...product,
...{
unit_price: '',
total_weight: '',
qty: '',
avg_weight: '',
total_price: '',
delivery_date: '',
},
}
: product
)
);
addDOModal.closeModal();
setSelectedDeliveryProduct(null);
},
[addDOModal]
);
useEffect(() => { useEffect(() => {
formik.setFieldValue('delivery_order', deliveryOrderValues); formik.setFieldValue('delivery_order', deliveryOrderValues);
@@ -580,6 +633,7 @@ const MarketingForm = ({
className={{ className={{
wrapper: 'bg-white w-full', wrapper: 'bg-white w-full',
}} }}
variant='bordered'
> >
<div className='grid sm:grid-cols-2 gap-3 mt-3'> <div className='grid sm:grid-cols-2 gap-3 mt-3'>
<SelectInput <SelectInput
@@ -588,6 +642,8 @@ const MarketingForm = ({
isLoading={isLoadingCustomerOptions} isLoading={isLoadingCustomerOptions}
value={formik.values.customer} value={formik.values.customer}
onChange={handleChangeCustomer} onChange={handleChangeCustomer}
onInputChange={setInputCustomerValue}
onMenuScrollToBottom={loadMoreCustomer}
isError={ isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id) formik.touched.customer_id && Boolean(formik.errors.customer_id)
} }
@@ -595,7 +651,9 @@ const MarketingForm = ({
isClearable isClearable
placeholder='Pilih Pelanggan' placeholder='Pilih Pelanggan'
isDisabled={ isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver' formType === 'add_deliver' ||
formType === 'edit_deliver' ||
formType === 'edit'
} }
/> />
<DateInput <DateInput
@@ -617,6 +675,7 @@ const MarketingForm = ({
className={{ className={{
wrapper: 'bg-white w-full', wrapper: 'bg-white w-full',
}} }}
variant='bordered'
> >
<MemoizedSalesOrderProductTable <MemoizedSalesOrderProductTable
formType={formType} formType={formType}
@@ -625,6 +684,7 @@ const MarketingForm = ({
setRowSelection={setRowSOSelection} setRowSelection={setRowSOSelection}
selectedRowIds={selectedRowSOIds} selectedRowIds={selectedRowSOIds}
onDelete={handleDeleteSO} onDelete={handleDeleteSO}
onEdit={handleEditSO}
onBulkDelete={handleBulkDeleteSO} onBulkDelete={handleBulkDeleteSO}
onAddProductClick={handleAddSOClick} onAddProductClick={handleAddSOClick}
/> />
@@ -644,6 +704,7 @@ const MarketingForm = ({
formType={formType} formType={formType}
data={deliveryOrderValues} data={deliveryOrderValues}
onEdit={handleEditDO} onEdit={handleEditDO}
onDelete={handleDeleteDO}
onAddProductClick={handleAddDOClick} onAddProductClick={handleAddDOClick}
/> />
</Card> </Card>
@@ -651,19 +712,42 @@ const MarketingForm = ({
{/* Input Notes */} {/* Input Notes */}
<div className='grid sm:grid-cols-2 gap-3'> <div className='grid sm:grid-cols-2 gap-3'>
<DebouncedTextArea <div className='flex flex-col h-full items-end gap-3'>
required <SelectInput
name='notes' label='Sales'
label='Catatan' options={salesOptions}
rows={3} isLoading={isLoadingSalesOptions}
placeholder='Masukan catatan penjualan' value={formik.values.sales_person}
value={formik.values.notes} onChange={handleChangeSalesPerson}
onChange={formik.handleChange} onInputChange={setInputSalesValue}
isError={formik.touched.notes && Boolean(formik.errors.notes)} onMenuScrollToBottom={loadMoreSales}
errorMessage={formik.errors.notes} isError={
disabled={formType === 'add_deliver' || formType === 'edit_deliver'} formik.touched.sales_person_id &&
/> Boolean(formik.errors.sales_person_id)
<div className='flex flex-col h-full justify-between items-end py-6'> }
errorMessage={formik.errors.sales_person_id}
isClearable
placeholder='Pilih Sales'
isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
<DebouncedTextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
</div>
<div className='flex flex-col h-full justify-end items-end'>
<span>Total Penjualan</span> <span>Total Penjualan</span>
<span className='text-lg font-semibold'> <span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}{' '} {formatCurrency(grandTotal)}{' '}
@@ -18,6 +18,11 @@ import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
import { ProductApi } from '@/services/api/master-data';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const DeliveryOrderProductForm = ({ const DeliveryOrderProductForm = ({
formState, formState,
@@ -43,6 +48,17 @@ const DeliveryOrderProductForm = ({
); );
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
const salesOrder = salesOrders.find( const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
@@ -90,6 +106,7 @@ const DeliveryOrderProductForm = ({
await onUpdateForm?.(values.marketing_product_id as number, values); await onUpdateForm?.(values.marketing_product_id as number, values);
} }
handleResetForm(); handleResetForm();
setSelectedProduct(null);
}, },
}); });
@@ -108,27 +125,84 @@ const DeliveryOrderProductForm = ({
marketing_product: undefined, marketing_product: undefined,
}, },
}); });
setSelectedProduct(null); // setSelectedProduct(null);
}; };
const handleBlurField = (field: string) => { const handleBlurField = (field: string) => {
setCurrentInput(field); setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { const qty = Number(formik.values.qty || 0);
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) { const avgWeight = Number(formik.values.avg_weight || 0);
formik.setFieldValue('total_price', Number(qty) * Number(unit_price)); const totalWeight = Number(formik.values.total_weight || 0);
} else if (qty && total_price && field === 'total_price') { const unitPrice = Number(formik.values.unit_price || 0);
formik.setFieldValue('unit_price', Number(total_price) / Number(qty)); const totalPrice = Number(formik.values.total_price || 0);
if (qty <= 0) return;
switch (field) {
// ===== SOURCE FIELDS =====
case 'qty': {
if (avgWeight > 0) {
const tw = roundWeight(qty * avgWeight);
formik.setFieldValue('total_weight', tw);
// Hitung total_price berdasarkan unit_price × total_weight
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(unitPrice * tw));
}
}
break;
} }
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') { case 'avg_weight': {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) { if (avgWeight > 0) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight)); const tw = roundWeight(qty * avgWeight);
} else if (qty && total_weight && field === 'total_weight') { formik.setFieldValue('total_weight', tw);
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
// Hitung total_price berdasarkan unit_price × total_weight
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(unitPrice * tw));
}
}
break;
}
case 'unit_price': {
if (unitPrice > 0 && totalWeight > 0) {
// Hitung total_price berdasarkan unit_price × total_weight
formik.setFieldValue(
'total_price',
roundPrice(unitPrice * totalWeight)
);
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
// Hitung ulang total_price berdasarkan unit_price × total_weight
if (unitPrice > 0) {
formik.setFieldValue(
'total_price',
roundPrice(unitPrice * totalWeight)
);
}
}
break;
}
case 'total_price': {
if (totalPrice > 0 && totalWeight > 0) {
// Hitung unit_price berdasarkan total_price / total_weight
formik.setFieldValue(
'unit_price',
roundPrice(totalPrice / totalWeight)
);
}
break;
} }
} }
}; };
@@ -183,7 +257,7 @@ const DeliveryOrderProductForm = ({
</div> </div>
)} )}
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-3 gap-4'>
<SelectInput <SelectInput
options={options} options={options}
label='Produk' label='Produk'
@@ -287,7 +361,9 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.vehicle_number)} isError={Boolean(formik.errors.vehicle_number)}
errorMessage={formik.errors.vehicle_number} errorMessage={formik.errors.vehicle_number}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4'>
<NumberInput <NumberInput
required required
label='Kuantitas' label='Kuantitas'
@@ -301,18 +377,42 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.qty)} isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
</span>
</div>
}
bottomLabel={ bottomLabel={
formik.values.marketing_product_id formik.values.marketing_product_id
? 'Stok dijual: ' + ? 'Stok dijual: ' +
salesOrders?.find( salesOrders?.find(
(item) => item.id === formik.values.marketing_product_id (item) => item.id === formik.values.marketing_product_id
)?.qty )?.qty +
' ' +
(isResponseSuccess(productData)
? productData?.data?.uom.name
: '')
: '' : ''
} }
/> />
</div> <NumberInput
<div className='divider my-6'></div> required
<div className='grid sm:grid-cols-2 gap-4'> label={`Harga / ${isResponseSuccess(productData) ? productData?.data?.uom?.name : 'Produk'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput <NumberInput
required required
label='Avg. Bobot (Kg)' label='Avg. Bobot (Kg)'
@@ -327,22 +427,6 @@ const DeliveryOrderProductForm = ({
errorMessage={formik.errors.avg_weight} errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata' placeholder='Masukan Bobot Rata-rata'
/> />
<NumberInput
required
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput <NumberInput
required required
label='Total Bobot (Kg)' label='Total Bobot (Kg)'
@@ -18,6 +18,7 @@ type SalesOrderProductSchemaType = {
avg_weight: string | number | undefined; avg_weight: string | number | undefined;
total_price: string | number | undefined; total_price: string | number | undefined;
vehicle_number?: string | undefined; vehicle_number?: string | undefined;
uom?: string | null | undefined;
}; };
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> = export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
@@ -57,6 +58,7 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
total_price: Yup.number() total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!') .min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'), .required('Total Penjualan wajib diisi!'),
uom: Yup.string().nullable().optional().notRequired(),
}); });
export type SalesOrderProductFormValues = Yup.InferType< export type SalesOrderProductFormValues = Yup.InferType<
@@ -11,7 +11,7 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data'; import { ProductApi, UomApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
@@ -26,6 +26,10 @@ import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -35,30 +39,36 @@ const SalesOrderProductForm = ({
initialValues?: SalesOrderProductFormValues; initialValues?: SalesOrderProductFormValues;
exisitingValues?: SalesOrderProductFormValues[]; exisitingValues?: SalesOrderProductFormValues[];
modalRef?: RefObject<HTMLDialogElement | null>; modalRef?: RefObject<HTMLDialogElement | null>;
onSubmitForm?: (value: SalesOrderProductFormValues) => Promise<void>; onSubmitForm?: (
value: SalesOrderProductFormValues,
id?: number
) => Promise<void>;
}) => { }) => {
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const [selectedProductWarehouse, setSelectedProductWarehouse] =
useState<ProductWarehouse | null>(null);
// ============ Formik ============ // ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({ const formik = useFormik<SalesOrderProductFormValues>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
vehicle_number: initialValues?.vehicle_number || undefined, vehicle_number: initialValues?.vehicle_number || '',
kandang_id: initialValues?.kandang_id || undefined, kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || undefined, kandang: initialValues?.kandang || null,
product_warehouse: initialValues?.product_warehouse || undefined, product_warehouse: initialValues?.product_warehouse || null,
product_warehouse_id: initialValues?.product_warehouse_id || undefined, product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialValues?.unit_price || undefined, unit_price: initialValues?.unit_price || '',
total_weight: initialValues?.total_weight || undefined, total_weight: initialValues?.total_weight || '',
qty: initialValues?.qty || undefined, qty: initialValues?.qty || '',
avg_weight: initialValues?.avg_weight || undefined, avg_weight: initialValues?.avg_weight || '',
total_price: initialValues?.total_price || undefined, total_price: initialValues?.total_price || '',
uom: initialValues?.uom || '',
}, },
validationSchema: SalesOrderProductSchema, validationSchema: SalesOrderProductSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
setFormErrorMessage(''); setFormErrorMessage('');
onSubmitForm?.(values); onSubmitForm?.(values, initialValues?.id);
handleResetForm(); handleResetForm();
}, },
validateOnBlur: true, validateOnBlur: true,
@@ -69,17 +79,21 @@ const SalesOrderProductForm = ({
const { const {
options: kandangSourceOptions, options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
const { const {
options: warehouseSourceOptions, options: warehouseSourceOptions,
rawData: warehouseSourceRawData, rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions, isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouse,
} = useSelect<ProductWarehouse>( } = useSelect<ProductWarehouse>(
ProductWarehouseApi.basePath, ProductWarehouseApi.basePath,
'id', 'id',
'product.name', 'product.name',
'search', '',
{ {
warehouse_id: formik.values.kandang_id?.toString() ?? '', warehouse_id: formik.values.kandang_id?.toString() ?? '',
} }
@@ -112,6 +126,7 @@ const SalesOrderProductForm = ({
const productWarehouse = warehouseSourceRawData?.data.find( const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === newId (item: ProductWarehouse) => item.id === newId
); );
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
@@ -139,40 +154,130 @@ const SalesOrderProductForm = ({
const handleBlurField = (field: string) => { const handleBlurField = (field: string) => {
setCurrentInput(field); setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { const qty = Number(formik.values.qty || 0);
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) { const avgWeight = Number(formik.values.avg_weight || 0);
formik.setFieldValue( const totalWeight = Number(formik.values.total_weight || 0);
'total_price', const unitPrice = Number(formik.values.unit_price || 0);
(qty as number) * (unit_price as number) const totalPrice = Number(formik.values.total_price || 0);
);
} else if (qty && total_price && field === 'total_price') { if (qty <= 0) return;
formik.setFieldValue(
'unit_price', // Cek apakah produk memiliki flag OVK atau PAKAN
(total_price as number) / (qty as number) const productFlags = selectedProductWarehouse?.product?.flags || [];
); const isOvkOrPakan =
productFlags.includes('OVK') || productFlags.includes('PAKAN');
switch (field) {
// ===== SOURCE FIELDS =====
case 'qty': {
if (avgWeight > 0) {
const tw = roundWeight(qty * avgWeight);
formik.setFieldValue('total_weight', tw);
// Hitung total_price berdasarkan flag produk
if (unitPrice > 0) {
if (isOvkOrPakan) {
// Untuk OVK/PAKAN: total_price = qty × unit_price
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
} else {
// Untuk produk lain: total_price = unit_price × total_weight
formik.setFieldValue('total_price', roundPrice(unitPrice * tw));
}
}
}
break;
} }
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') { case 'avg_weight': {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) { if (avgWeight > 0) {
formik.setFieldValue( const tw = roundWeight(qty * avgWeight);
'total_weight', formik.setFieldValue('total_weight', tw);
(qty as number) * (avg_weight as number)
); // Hitung total_price berdasarkan flag produk
} else if (qty && total_weight && field === 'total_weight') { if (unitPrice > 0) {
formik.setFieldValue( if (isOvkOrPakan) {
'avg_weight', // Untuk OVK/PAKAN: total_price = qty × unit_price
(total_weight as number) / (qty as number) formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
); } else {
// Untuk produk lain: total_price = unit_price × total_weight
formik.setFieldValue('total_price', roundPrice(unitPrice * tw));
}
}
}
break;
}
case 'unit_price': {
if (unitPrice > 0) {
if (isOvkOrPakan) {
// Untuk OVK/PAKAN: total_price = qty × unit_price
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
} else if (totalWeight > 0) {
// Untuk produk lain: total_price = unit_price × total_weight
formik.setFieldValue(
'total_price',
roundPrice(unitPrice * totalWeight)
);
}
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
// Hitung ulang total_price berdasarkan flag produk
if (unitPrice > 0) {
if (isOvkOrPakan) {
// Untuk OVK/PAKAN: total_price = qty × unit_price
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
} else {
// Untuk produk lain: total_price = unit_price × total_weight
formik.setFieldValue(
'total_price',
roundPrice(unitPrice * totalWeight)
);
}
}
}
break;
}
case 'total_price': {
if (totalPrice > 0) {
if (isOvkOrPakan && qty > 0) {
// Untuk OVK/PAKAN: unit_price = total_price / qty
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
} else if (totalWeight > 0) {
// Untuk produk lain: unit_price = total_price / total_weight
formik.setFieldValue(
'unit_price',
roundPrice(totalPrice / totalWeight)
);
}
}
break;
} }
} }
}; };
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
formik,
{
onBeforeSubmit(e) {
e.preventDefault();
handleBlurField(currentInput);
formik.setFieldValue(
'uom',
selectedProductWarehouse?.product?.uom?.name
);
},
}
);
return ( return (
<> <>
@@ -188,7 +293,7 @@ const SalesOrderProductForm = ({
</Alert> </Alert>
</div> </div>
)} )}
<div className='grid sm:grid-cols-2 gap-4 z-200'> <div className='grid sm:grid-cols-3 gap-4 z-200'>
<PatternInput <PatternInput
name='vehicle_number' name='vehicle_number'
label='No. Polisi' label='No. Polisi'
@@ -215,6 +320,8 @@ const SalesOrderProductForm = ({
value={formik.values.kandang} value={formik.values.kandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
isClearable isClearable
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -228,6 +335,8 @@ const SalesOrderProductForm = ({
isLoading={isLoadingWarehouseSourceOptions} isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse} value={formik.values.product_warehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
isClearable isClearable
placeholder={ placeholder={
formik.values.kandang_id formik.values.kandang_id
@@ -243,6 +352,9 @@ const SalesOrderProductForm = ({
} }
errorMessage={formik.errors.product_warehouse_id} errorMessage={formik.errors.product_warehouse_id}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4 z-200'>
<NumberInput <NumberInput
required required
label='Kuantitas' label='Kuantitas'
@@ -256,6 +368,13 @@ const SalesOrderProductForm = ({
isError={formik.touched.qty && Boolean(formik.errors.qty)} isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{selectedProductWarehouse?.product?.uom?.name}
</span>
</div>
}
bottomLabel={ bottomLabel={
isResponseSuccess(warehouseSourceRawData) && isResponseSuccess(warehouseSourceRawData) &&
formik.values.product_warehouse_id formik.values.product_warehouse_id
@@ -263,17 +382,26 @@ const SalesOrderProductForm = ({
warehouseSourceRawData?.data?.find( warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id (item) => item.id === formik.values.product_warehouse_id
)?.quantity ?? 0 )?.quantity ?? 0
)} ${ )} ${selectedProductWarehouse?.product?.uom?.name}`
warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id
)?.product?.uom?.name ?? ''
}`
: '' : ''
} }
/> />
</div> <NumberInput
<div className='divider my-6'></div> required
<div className='grid sm:grid-cols-2 gap-4 z-200'> label={`Harga / ${selectedProductWarehouse?.product?.uom?.name ?? 'Produk'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput <NumberInput
required required
label='Avg. Bobot (Kg)' label='Avg. Bobot (Kg)'
@@ -290,22 +418,6 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.avg_weight} errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata' placeholder='Masukan Bobot Rata-rata'
/> />
<NumberInput
required
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput <NumberInput
required required
label='Total Bobot (Kg)' label='Total Bobot (Kg)'
@@ -340,7 +452,9 @@ const SalesOrderProductForm = ({
/> />
</div> </div>
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <div className='mt-4'>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
<div className='flex flex-row justify-end gap-3 mt-4'> <div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning' onClick={handleResetForm}> <Button type='reset' color='warning' onClick={handleResetForm}>
@@ -16,6 +16,7 @@ type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[]; data: DeliveryOrderProductFormValues[];
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver'; formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
onEdit: (id: number) => void; onEdit: (id: number) => void;
onDelete: (id: number) => void;
onAddProductClick: () => void; onAddProductClick: () => void;
}; };
@@ -23,10 +24,13 @@ const DeliveryOrderProductTable = ({
data, data,
formType, formType,
onEdit, onEdit,
onDelete,
onAddProductClick, onAddProductClick,
}: DeliveryOrderProductTableProps) => { }: DeliveryOrderProductTableProps) => {
const onEditRef = useRef(onEdit); const onEditRef = useRef(onEdit);
onEditRef.current = onEdit; onEditRef.current = onEdit;
const onDeleteRef = useRef(onDelete);
onDeleteRef.current = onDelete;
const canAddData = data.filter((item) => !Boolean(item.qty)); const canAddData = data.filter((item) => !Boolean(item.qty));
@@ -144,16 +148,29 @@ const DeliveryOrderProductTable = ({
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'> <div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<> <>
{props.row.original.qty && ( {props.row.original.qty && (
<Button <>
color='warning' <Button
className='px-2 py-1 text-sm' color='warning'
onClick={() => className='px-2 py-1 text-sm'
onEditRef.current(props.row.original.id as number) onClick={() =>
} onEditRef.current(props.row.original.id as number)
type='button' }
> type='button'
<Icon icon='mdi:edit' width={16} height={16} /> Edit >
</Button> <Icon icon='mdi:edit' width={16} height={16} /> Edit
</Button>
<Button
color='error'
className='px-2 py-1 text-sm'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
disabled={!!props.row.original.do_number}
>
<Icon icon='mdi:delete' width={16} height={16} /> Hapus
</Button>
</>
)} )}
{!props.row.original.qty && '-'} {!props.row.original.qty && '-'}
</> </>

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