Compare commits

..

209 Commits

Author SHA1 Message Date
kris ead5ba759d Update .gitlab-ci.yml file 2025-12-18 10:00:38 +00:00
Adnan Zahir 224f7ddeea Merge branch 'feat/FE/US-284/sapronak-calculation-report' into 'development'
[FEAT/FE][US#284] Add Feature Perhitungan Sapronak Closing Report

See merge request mbugroup/lti-web-client!98
2025-12-16 15:00:47 +07:00
randy-ar f58b03ba0e fix(FE): revert auth component 2025-12-16 14:24:52 +07:00
randy-ar d348cee4e6 fix(FE): resolve merge conflict 2025-12-16 14:17:23 +07:00
Adnan Zahir be238779a4 Merge branch 'dev/hotfix/restu' into 'development'
[HOTFIX/FE] Fixing Recording and Approval Button

See merge request mbugroup/lti-web-client!97
2025-12-16 13:57:24 +07:00
Adnan Zahir 8db7b6d5e9 Merge branch 'feat/FE/US-279/closing-button' into 'development'
[FEAT/FE][US#279] Add Feature Closing Produksi (Project Flock)

See merge request mbugroup/lti-web-client!96
2025-12-16 13:56:20 +07:00
Rivaldi A N S ebe752b27b Merge branch 'feat/FE/US-284/TASK-324-325-slicing-and-integration-sapronak-calculation-closing-report' into 'feat/FE/US-284/sapronak-calculation-report'
[FEAT/FE][US#284/TASK#324-325] Add Feature Perhitungan Sapronak Closing Report

See merge request mbugroup/lti-web-client!71
2025-12-15 07:54:59 +00:00
rstubryan 3dd36b8248 fix(FE): Parse recordingId and hide actions for rejected 2025-12-11 11:05:20 +07:00
rstubryan 12698004e1 fix(FE): Update recording detail links to include production path 2025-12-11 10:47:25 +07:00
rstubryan a0ca8e8f69 Merge branch 'feat/FE/US-279/closing-button' of gitlab.com:mbugroup/lti-web-client into dev/hotfix/restu 2025-12-11 10:46:52 +07:00
rstubryan 69206d4524 fix(FE): Update recording detail links to include production path 2025-12-11 10:46:38 +07:00
rstubryan a73f9a1acd fix(FE): Update recording detail links to include production path 2025-12-11 10:46:21 +07:00
Rivaldi A N S 48649df409 Merge branch 'dev/randy' into 'feat/FE/US-279/closing-button'
[FEAT/FE][US#279/TASK#312-313] Add Feature Closing Produksi (Project Flock)

See merge request mbugroup/lti-web-client!72
2025-12-10 18:06:46 +00:00
randy-ar c53f9352be fix(FE): closing project flock & merge development 2025-12-11 00:32:54 +07:00
Adnan Zahir df632526d2 Merge branch 'dev/hotfix/restu' into 'development'
[HOTFIX/FE] Fixing Credit Term issue on Purchase

See merge request mbugroup/lti-web-client!95
2025-12-11 00:10:33 +07:00
rstubryan 4ec455b3b7 feat(FE): Add credit_term to purchase forms and types 2025-12-10 23:54:59 +07:00
randy-ar 4f4fd3e6b7 fix(FE): pull development 2025-12-10 23:19:43 +07:00
Adnan Zahir 0d7dd0a110 Merge branch 'feat/FE/US-278/purchase-and-expense' into 'development'
[FEAT/FE][US#278] Adjust Purchase Request and Purchase Order (Expense Extended)

See merge request mbugroup/lti-web-client!86
2025-12-10 23:18:50 +07:00
Adnan Zahir 9bf4fd585d Merge branch 'feat/FE/US-282/egg-grading-adjustment' into 'development'
[FEAT/FE][US#282] Adjustment Recording Egg Grading

See merge request mbugroup/lti-web-client!85
2025-12-10 23:18:20 +07:00
kris a77a360410 Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!94
2025-12-10 15:31:00 +00:00
ValdiANS 9628ee88ad chore: add condition for redirecting to SSO 2025-12-10 21:47:58 +07:00
ValdiANS 4356bd8803 fix: remove redirectToSSO 2025-12-10 21:43:05 +07:00
kris cbf1660da5 Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!93
2025-12-10 11:53:47 +00:00
ValdiANS 37f59f9470 fix: remove unnecessary code 2025-12-10 18:50:58 +07:00
kris 6e3b25eb98 Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!92
2025-12-10 11:11:50 +00:00
ValdiANS f939f4b0fb fix: return children only if userResponse success and user is set 2025-12-10 18:10:08 +07:00
ValdiANS 720ff2128f fix: add use-client and export dynamic 2025-12-10 18:09:30 +07:00
ValdiANS 280fffe6a5 fix: add use-client 2025-12-10 18:09:21 +07:00
ValdiANS 6340a5e519 fix: export dynamic 2025-12-10 18:09:10 +07:00
kris d4fc0b4a4f Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!91
2025-12-10 10:32:40 +00:00
ValdiANS 4f595c7cad chore: wrap router.replace in useEffect 2025-12-10 17:31:21 +07:00
ValdiANS 3826b8ea53 feat: set trailingSlash to true 2025-12-10 17:31:06 +07:00
kris 5cc82f1615 Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!90
2025-12-10 10:16:33 +00:00
ValdiANS cfaac14820 chore: return loading text if all condition unmet 2025-12-10 17:15:23 +07:00
kris b39d1f5c2e Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!89
2025-12-10 10:08:42 +00:00
ValdiANS 30ab48e426 fix: redirect to dashboard if pathname is in root path 2025-12-10 17:07:44 +07:00
kris 88c640df18 Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!88
2025-12-10 10:00:08 +00:00
ValdiANS 017b081832 fix: redirect to SSO if user isnt exist and show loading state if still loading user 2025-12-10 16:57:45 +07:00
ValdiANS 83d76f7de4 fix: set isLoadingUser in useAuth hook 2025-12-10 16:57:20 +07:00
randy-ar 9af140e58d fix(FE): fix merge conflict 2025-12-10 16:56:25 +07:00
randy-ar 654aa50cc7 fix(FE): fix merge conflict 2025-12-10 16:53:50 +07:00
randy-ar 814e8db1ba fix(FE): resolve merge conflict development 2025-12-10 16:45:54 +07:00
randy-ar d1883654bc fix(FE): resolve merge conflict development 2025-12-10 16:44:52 +07:00
kris 2c6ad71fd3 Update .gitlab-ci.yml file 2025-12-10 09:42:13 +00:00
randy-ar 6c31d933b0 fix(FE): resolve merge conflict 2025-12-10 16:41:21 +07:00
Mitra Berlian Unggas b806c0f0a1 Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!87
2025-12-10 09:36:29 +00:00
randy-ar a073488c2c fix(FE): fixing closing project flock and fetching data in closing report 2025-12-10 16:32:50 +07:00
Rivaldi A N S 7efb2a4dbb Merge branch 'feat/FE/US-278/TASK-311-adjustment-purchase-and-expense' into 'feat/FE/US-278/purchase-and-expense'
[FEAT/FE][US#278/TASK-311] Adjust Purchase Request and Purchase Order (Expense Extended)

See merge request mbugroup/lti-web-client!83
2025-12-10 09:27:50 +00:00
Rivaldi A N S 01d1ed8f0d Merge branch 'feat/FE/US-282/TASK-318-319-slicing-and-adjustment-egg-grading-form' into 'feat/FE/US-282/egg-grading-adjustment'
[FEAT/FE][US#282/TASK-318-319] Adjustment Recording Egg Grading

See merge request mbugroup/lti-web-client!84
2025-12-10 09:25:32 +00:00
ValdiANS aed58ef10c hotfix: Implement client-side dashboard redirect with loading spinner, improve authentication error handling by clearing user state on 401, and extend SSO redirect loop protection. 2025-12-10 16:23:51 +07:00
rstubryan f105852a07 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-282/TASK-318-319-slicing-and-adjustment-egg-grading-form 2025-12-10 15:52:30 +07:00
rstubryan 21d6fc8579 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-278/TASK-311-adjustment-purchase-and-expense 2025-12-10 15:44:58 +07:00
kris eea1fcb513 Update .gitlab-ci.yml file 2025-12-10 08:43:08 +00:00
rstubryan 7c4d5e68fa Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-282/TASK-318-319-slicing-and-adjustment-egg-grading-form 2025-12-10 15:36:36 +07:00
rstubryan b74e43c483 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-278/TASK-311-adjustment-purchase-and-expense 2025-12-10 15:36:12 +07:00
Adnan Zahir dbff1bda3d Merge branch 'dev/hotfix/restu' into 'development'
[HOTFIX/FE] Fixing Penjualan Tabs (Misplacement Issue)

See merge request mbugroup/lti-web-client!81
2025-12-10 15:33:54 +07:00
Adnan Zahir 244c564f06 Merge branch 'fix/redirect-error' into 'development'
[HOTFIX/FE] Fixing redirect issues

See merge request mbugroup/lti-web-client!82
2025-12-10 15:31:31 +07:00
ValdiANS 757e0435ac hotfix: use redirectToSSO function 2025-12-10 15:21:46 +07:00
ValdiANS 46d70e36dd feat: create auth-helper file and redirectToSSO helper function 2025-12-10 15:21:10 +07:00
ValdiANS 0cc9d0e94e hotfix: Centralize SSO redirection logic into a new helper with loop protection, integrate it into the HTTP client and RequireAuth component, and add an authentication failure UI. 2025-12-10 15:18:37 +07:00
rstubryan d7199fad53 hotfix(FE): Pass sales data to ClosingDetail and fix sales API 2025-12-10 15:05:52 +07:00
rstubryan 8c2683c440 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2025-12-10 14:34:12 +07:00
rstubryan b9e69b243f Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-282/TASK-318-319-slicing-and-adjustment-egg-grading-form 2025-12-10 13:59:04 +07:00
rstubryan 8dec4915a2 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-278/TASK-311-adjustment-purchase-and-expense 2025-12-10 13:57:07 +07:00
rstubryan 7be32326a9 feat(FE-311): Disable approval actions when rejected 2025-12-10 13:56:13 +07:00
randy-ar c416fdbdaa fix(FE): resolve conflit merge development 2025-12-10 13:51:19 +07:00
Adnan Zahir 270e8ff0c6 Merge branch 'dev/hot-fix/randy' into 'development'
[HOTFIX/FE] Fixing Dropdown Button Logout

See merge request mbugroup/lti-web-client!80
2025-12-10 13:44:20 +07:00
Adnan Zahir 64abc5001d Merge branch 'feat/FE/US-285/marketing-closing-report' into 'development'
[FEAT/FE][US#285] Add Feature Marketing Closing Report (Sales/Penjualan)

See merge request mbugroup/lti-web-client!77
2025-12-10 13:38:26 +07:00
randy-ar f48cfca650 fix(FE): revert require auth component 2025-12-10 13:35:42 +07:00
rstubryan a116f7ca66 fix(FE): Remove closing detail page and layout 2025-12-10 13:32:29 +07:00
rstubryan 429f5ffb62 feat(FE-311): Add rejection modals and accept handler 2025-12-10 13:30:40 +07:00
randy-ar eed142a85f hotfix(FE): fixing dropdown logout and floating button max size 2025-12-10 13:25:07 +07:00
rstubryan 48f228de1c Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-278/TASK-311-adjustment-purchase-and-expense 2025-12-10 13:08:36 +07:00
rstubryan c92abfc9ab Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-285/marketing-closing-report 2025-12-10 11:54:05 +07:00
rstubryan 7e999b2e34 feat(FE): Show sales report on closing detail page 2025-12-10 11:53:47 +07:00
rstubryan e90c7d993c Merge branch ‘development’ of gitlab.com:mbugroup/lti-web-client into
feat/FE/US-285/marketing-closing-report
2025-12-10 11:44:46 +07:00
rstubryan 99fbcaaea3 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-285/marketing-closing-report 2025-12-10 11:40:43 +07:00
Adnan Zahir 865b0b3d8f Merge branch 'fix/FE/US-163-164/api-integration-adjustment-in-expense-request-and-realization' into 'development'
[FIX/FE][US#163-164] API Integration Adjustment in Expense Request and Realization

See merge request mbugroup/lti-web-client!78
2025-12-10 11:32:21 +07:00
Adnan Zahir a4c83f99a7 Merge branch 'feat/FE/US-286/inventory-management-product-stock' into 'development'
[FEAT/FE][US#286] Inventory Product Stock

See merge request mbugroup/lti-web-client!76
2025-12-10 11:25:27 +07:00
Adnan Zahir 294c971fea Merge branch 'feat/FE/US-280/project-flock-budget' into 'development'
[FEAT/FE][US#280] Project Flock Budgets

See merge request mbugroup/lti-web-client!75
2025-12-10 11:14:07 +07:00
randy-ar 8a8128a692 fix(FE): resolve merge conflict checkbox and constant 2025-12-10 10:09:56 +07:00
randy-ar 649dd70ea7 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into dev/randy 2025-12-10 10:03:28 +07:00
Adnan Zahir 44b9210ccc Merge branch 'feat/FE/US-283/sapronak-closing-report' into 'development'
[FEAT/FE][US#283] Sapronak Closing Report

See merge request mbugroup/lti-web-client!74
2025-12-10 09:42:35 +07:00
randy-ar 8c7640eb9c feat(FE-333): adding feature overhead closing report 2025-12-09 18:14:46 +07:00
randy-ar 489815ecaf fix(FE): revert require auth component 2025-12-09 18:04:06 +07:00
randy-ar f9dfe7b27f feat(FE-284): Refactor table component support for nesting header 2025-12-09 17:57:46 +07:00
ValdiANS 33691f45bb chore(FE-323): update outgoing sapronak API endpoint 2025-12-09 11:38:57 +07:00
ValdiANS 2c72c44be4 chore(FE-323): update incoming sapronak API endpoint 2025-12-09 11:38:53 +07:00
ValdiANS 98dfd4564c chore(FE-323): change closing API route path 2025-12-09 11:37:26 +07:00
ValdiANS a795d78c80 Merge branch 'development' into feat/FE/US-283/sapronak-closing-report 2025-12-09 10:58:43 +07:00
randy-ar 8a0adf847e fix(FE-279): adjust closing project flock kandang 2025-12-09 10:33:38 +07:00
rstubryan 8e80d668fa refactor(FE-311): Remove credit_term from purchase request data and UI 2025-12-09 10:13:48 +07:00
rstubryan a45de4fb13 refactor(FE-311): Remove grand_total and due_date from purchases 2025-12-09 09:58:15 +07:00
rstubryan 6ee5bc3f1b refactor(FE-318,319): Remove egg grading schema and UI logic 2025-12-08 23:37:20 +07:00
rstubryan 012fe800bc refactor(FE-318,319): Remove laying grading checks and simplify approval 2025-12-08 23:35:55 +07:00
rstubryan c3835d5128 refactor(FE-319): Renumber RECORDINGS approval workflow steps 2025-12-08 23:35:12 +07:00
rstubryan 7c4bd81364 feat(FE-319): Remove recording grading feature 2025-12-08 23:34:01 +07:00
rstubryan 545af8267a feat(FE-319): Refactor recording types and simplify payloads 2025-12-08 23:33:34 +07:00
rstubryan 2e6a724b2f refactor(FE-319): Use approval step 2 and remove grading button 2025-12-08 20:13:10 +07:00
rstubryan 305b8e5005 refactor(FE-319): Remove Grading-Telur step from RECORDINGS workflow 2025-12-08 20:12:18 +07:00
rstubryan 5deca5739f refactor(FE-318): Add egg weight column and separate inputs 2025-12-08 20:08:35 +07:00
rstubryan b464432581 chore(FE): Add .claude to .gitignore 2025-12-08 18:49:17 +07:00
rstubryan 512ad5175e refactor(FE-311): Default received_qty and remove transport_total 2025-12-08 18:37:58 +07:00
rstubryan a7d884b5f0 refactor(FE-311): Use latest_approval instead of approval 2025-12-08 18:14:38 +07:00
rstubryan ce75eb25d7 refactor(FE-311): Show previous values only in edit mode 2025-12-08 17:55:22 +07:00
rstubryan c7911f01f2 refactor(FE-311): Remove Total Transport header from approval form 2025-12-08 17:43:44 +07:00
rstubryan 68874a1c14 feat(FE-311): Use latest_approval for purchase approvals 2025-12-08 17:42:23 +07:00
rstubryan 7cc2a31745 Merge branch 'feat/FE/US-280/project-flock-budget' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-278/TASK-311-adjustment-purchase-and-expense 2025-12-08 16:28:09 +07:00
rstubryan f5663b82aa refactor(ci): clean up .gitlab-ci.yml by removing unnecessary whitespace 2025-12-08 14:43:53 +07:00
rstubryan 3a7f1f4812 refactor(FE-311): remove transport_total field and update approval actions 2025-12-08 14:42:39 +07:00
rstubryan 32ffc1f14c chore(prettier): Remove trailing whitespace in .gitlab-ci.yml 2025-12-08 14:08:40 +07:00
rstubryan 58fb9b0c08 chore(CVE): Bump Next to 15.5.7 and ignore .claude 2025-12-08 14:07:25 +07:00
randy-ar 3569955e7f fix(FE): fix warn issue next js 2025-12-08 14:01:13 +07:00
randy-ar 7df743ebf5 fix(FE): adjust sapronak calculation to closing detail page 2025-12-08 10:24:41 +07:00
rstubryan 86a0faaa52 chore(ci): clean up .gitlab-ci.yml by removing unnecessary whitespace 2025-12-08 10:09:21 +07:00
rstubryan df3f342214 chore(CVE): update Next.js version to ^15.5.7 in package.json and package-lock.json 2025-12-08 10:07:00 +07:00
rstubryan c3c1bbbe96 feat(FE-326): Add egg weight field to recording forms 2025-12-08 09:47:22 +07:00
ValdiANS dc0fd7a3ed chore: format code 2025-12-07 15:00:25 +07:00
ValdiANS 5782abb531 refactor: change expense_date to transaction_date 2025-12-07 14:59:51 +07:00
ValdiANS 2d1cabb86b refactor: update CreateExpensePayload, UpdateExpensePayload, and CreateExpenseRealizationPayload types 2025-12-07 14:59:25 +07:00
Rivaldi A N S b362fd1748 Merge branch 'feat/FE/US-285/TASK-326-327-slicing-and-integration-marketing-closing-report' into 'feat/FE/US-285/marketing-closing-report'
[FEAT/FE][US#285/TASK#326-327] Add Feature Marketing Closing Report (Sales/Penjualan)

See merge request mbugroup/lti-web-client!70
2025-12-06 10:25:38 +00:00
Rivaldi A N S 3411aa9b1b Merge branch 'dev/randy' into 'feat/FE/US-286/inventory-management-product-stock'
[FEAT/FE][US#286] Inventory Product Stock

See merge request mbugroup/lti-web-client!69
2025-12-06 10:25:19 +00:00
Rivaldi A N S 1f29e3cb50 Merge branch 'dev/randy' into 'feat/FE/US-280/project-flock-budget'
[FEAT/FE][US#280] Project Flock Budgets

See merge request mbugroup/lti-web-client!68
2025-12-06 10:23:34 +00:00
Rivaldi A N S b671de1336 Merge branch 'feat/FE/US-283/TASK-320-321-322-323-sapronak-closing-report' into 'feat/FE/US-283/sapronak-closing-report'
[FEAT/FE][US#283/TASK#320-321-322-323] Sapronak Closing Report

See merge request mbugroup/lti-web-client!73
2025-12-06 10:18:01 +00:00
ValdiANS 090a3183f7 feat(FE-323): create Closing type 2025-12-06 17:14:09 +07:00
ValdiANS 17865d733d feat(FE-323): create ClosingApiService 2025-12-06 17:13:53 +07:00
ValdiANS 5be67ef01c chore: update formatDate helper function 2025-12-06 17:13:27 +07:00
ValdiANS 7f326bedd4 chore(FE-320): add Closing menu 2025-12-06 17:13:00 +07:00
ValdiANS c350bc0be2 feat(FE-321): create ClosingSapronakTabContent component 2025-12-06 16:54:44 +07:00
ValdiANS 6f7627ac92 feat(FE-321): create ClosingOutgoingSapronaksTable component 2025-12-06 16:54:27 +07:00
ValdiANS 1ae5c1bd64 feat(FE-321): create ClosingIncomingSapronaksTable component 2025-12-06 16:54:15 +07:00
ValdiANS 5bb366026d feat(FE-321): create ClosingGeneralInformationTable component 2025-12-06 16:53:54 +07:00
ValdiANS 9888dc4356 feat(FE-321): create ClosingDetail component 2025-12-06 16:53:39 +07:00
ValdiANS 7615daa22a chore: update Pagination component 2025-12-06 16:53:20 +07:00
ValdiANS 435cc0aedc feat(FE-321): create layout file for closing detail route 2025-12-06 16:53:05 +07:00
ValdiANS d189252551 feat(FE-321): create Closing detail page 2025-12-06 16:52:45 +07:00
ValdiANS d85cf29193 feat(FE-320): create ClosingsTable component 2025-12-06 16:52:12 +07:00
ValdiANS 84ff5e178b feat(FE-320): create Closing list page 2025-12-06 16:51:48 +07:00
ValdiANS 72840e2193 chore: set container size value 2025-12-06 16:46:14 +07:00
ValdiANS ea2ada8224 chore: update daisyui version 2025-12-06 16:44:31 +07:00
randy-ar b97cc39854 fix(FE): revert RequireAuth component and closing logic 2025-12-06 13:10:03 +07:00
randy-ar 195bbbe449 fix(FE): change closing folder name 2025-12-06 12:51:13 +07:00
randy-ar 375b50b646 fix(FE): revert RequireAuth Component 2025-12-06 12:45:07 +07:00
randy-ar a5c71ff8ce feat(FE-284): Slicing and API Integration Perhitungan Sapronak 2025-12-06 12:43:22 +07:00
randy-ar e09074eed0 feat(FE): add sapronak table 2025-12-06 11:55:47 +07:00
randy-ar ffbf886718 fix(FE): adjust chickin and closing after submit 2025-12-06 11:38:28 +07:00
rstubryan b3f7b8a3c5 feat(FE-326): Add totals footer row to sales report table 2025-12-06 10:26:26 +07:00
rstubryan e407410c4a feat(FE-Storyless): Add footer support to Table component 2025-12-06 10:25:40 +07:00
randy-ar 341cb42452 feat(FE): adding temporary perhitungan sapronak 2025-12-06 10:05:10 +07:00
rstubryan 99b9df27a7 refactor(FE-326): Comment _closing for copy-paste function 2025-12-06 09:58:38 +07:00
rstubryan 27c867036f refactor(FE-327): Update import paths for consistency in SalesReportTable 2025-12-06 09:51:40 +07:00
rstubryan c9552dec2d refactor(FE-326): Remove custom header rows and simplify Table 2025-12-06 09:47:38 +07:00
rstubryan aad24c3c58 refactor(FE-327): Rename salesBroilerData to salesData 2025-12-06 09:12:02 +07:00
rstubryan ff1493b520 refactor(FE-326): Remove avgPriceAct/totalAct and use partner totals,
fix badge case
2025-12-06 09:09:41 +07:00
rstubryan 4ff1649991 chore(FE-327): Remove unused state from SalesReportTable 2025-12-06 08:56:14 +07:00
rstubryan 4fe53f364a refactor(FE-326): Remove Tabs wrapper from SalesReportTable 2025-12-06 08:54:12 +07:00
randy-ar 85fdb4f7dd refactor(FE): refactor chickin views and adjust approval logic in project flocks 2025-12-06 00:15:30 +07:00
randy-ar 885e4250fd feat(FE-279): Add functionality closing project flock 2025-12-05 22:55:11 +07:00
rstubryan eaf118845c feat(FE-327): Include Kandang in sales data and display name 2025-12-05 19:15:38 +07:00
rstubryan 30db7ee95d refactor(FE-327): change SalesReportTable to use new API fields 2025-12-05 18:27:45 +07:00
rstubryan 5869e0434b refactor(FE-327): change closing API paths and sales types 2025-12-05 18:26:58 +07:00
rstubryan f205c66509 refactor(FE-327): Rename Ekor label to Kuantitas 2025-12-05 17:49:59 +07:00
rstubryan 46e072bbcf refactor(FE-327): Map Indonesian sales fields and add API sample 2025-12-05 11:15:41 +07:00
rstubryan c31b284cf4 refactor(FE-327): Split BaseClosingSales into BaseSales and wrapper 2025-12-05 11:14:52 +07:00
ValdiANS bac3f30ce3 chore: update Table component 2025-12-04 23:09:08 +07:00
ValdiANS be725d42c3 chore: add Size type 2025-12-04 22:46:26 +07:00
ValdiANS b37c3f87b0 chore: set color for menu foreground and background 2025-12-04 22:46:18 +07:00
ValdiANS ae4c17b391 chore: create isPathActive helper 2025-12-04 22:45:57 +07:00
ValdiANS 48dd6d7218 chore: update MAIN_DRAWER_LINKS structure 2025-12-04 22:45:48 +07:00
ValdiANS cee3d4ba90 chore: create SidebarMenu component 2025-12-04 22:45:29 +07:00
ValdiANS a8d7fdc30d chore: update Menu component 2025-12-04 22:45:20 +07:00
ValdiANS 2bb2da74e6 chore: update CheckboxInput component 2025-12-04 22:45:13 +07:00
ValdiANS fd024fdb8f chore: update Pagination component 2025-12-04 22:44:43 +07:00
ValdiANS 79a89ea193 chore: use SidebarMenu component 2025-12-04 22:44:17 +07:00
ValdiANS 611655e408 chore: update gitlab-ci 2025-12-04 22:42:57 +07:00
ValdiANS 702943c55c chore: update next, daisyui, and eslint-config-next library 2025-12-04 22:36:22 +07:00
rstubryan 075d945a59 refactor(FE-326): Use placeholder for sales type in header 2025-12-04 21:29:45 +07:00
rstubryan 7d9a88cf3b feat(FE-326,327): Add sortable table headers and styling 2025-12-04 20:14:29 +07:00
rstubryan b095208fae refactor(FE-327): Temporarily map Indonesian sales fields to English 2025-12-04 17:41:22 +07:00
randy-ar c69d9dd605 fix(FE): revert require auth component to correct file 2025-12-04 16:39:00 +07:00
randy-ar a1d0c7b331 fix(FE): adjust data types for inventory product stock 2025-12-04 16:35:10 +07:00
randy-ar e0a8514814 fix(FE): adjust data types for project flock and product stock inventory 2025-12-04 16:13:47 +07:00
rstubryan 949761d59d feat(FE-326,327): add footer rendering to SalesReportTable for total sales display 2025-12-04 14:25:27 +07:00
rstubryan 15ced14e20 refactor(FE-Storyless): add footer rendering support to Table component 2025-12-04 14:25:11 +07:00
rstubryan 492efb18e2 chore: update next.js to version 15.5.7 in package.json and package-lock.json 2025-12-04 14:15:58 +07:00
rstubryan 8ea29579ec feat(FE-326,327): add layout and closing detail page with sales report integration 2025-12-04 14:10:57 +07:00
randy-ar dc6b0eaec6 fix(FE): change datatype location in inventory products 2025-12-04 14:10:46 +07:00
rstubryan 1a4a05308f refactor(FE-326,327): enhance SalesReportTable to handle empty sales data and conditionally render summary row 2025-12-04 14:08:44 +07:00
rstubryan ba40bbb1d3 refactor(FE-327): remove dummy sales data and update ClosingApiService to fetch real sales data from API 2025-12-04 14:07:10 +07:00
rstubryan 647b002065 refactor(FE-326): change SalesReportTable to use BaseClosingSales type and support initial values 2025-12-04 11:55:57 +07:00
rstubryan 991a594ee1 refactor(FE-326): Add ClosingApiService and types for closing sales data 2025-12-04 11:51:11 +07:00
randy-ar 3b846bf11c fix(FE): fixing error suspense layout 2025-12-04 02:06:33 +07:00
randy-ar 3e07316678 feat(FE-328-329-330): Adding Feature Inventory Product Stocks 2025-12-04 02:05:34 +07:00
rstubryan 411c2586f5 chore(format): prettier format 2025-12-03 22:32:11 +07:00
rstubryan 3a87b039bf feat(FE-326): Add SalesReportTable component 2025-12-03 22:31:10 +07:00
rstubryan 50559caf52 feat(FE-326): Support custom header rows and cell render hook 2025-12-03 22:28:18 +07:00
rstubryan 8fbe6aa148 chore(FE-Storyless): Add .claude to .gitignore 2025-12-03 22:26:33 +07:00
randy-ar 873a4b308d fix(FE): resolve conflict pull branch development 2025-12-03 21:21:42 +07:00
randy-ar f0ec758d7f feat(FE-314-315): API Integration project budgets and refactoring UI 2025-12-03 21:09:12 +07:00
rstubryan 88878f7613 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2025-12-03 10:55:39 +07:00
randy-ar 31f758d680 refactor(FE): refactor UI detail from page into drawer 2025-12-02 16:25:55 +07:00
randy-ar 9eba5ffeca feat(FE): create floation actions button 2025-12-02 12:37:03 +07:00
randy-ar 6b5838b5aa feat(FE): refactor drawer zustand store 2025-12-02 11:01:53 +07:00
randy-ar c76f3a3715 feat(FE): US#278 slicing UI from and client side validation 2025-12-02 04:11:01 +07:00
randy-ar 48435a9cbb fix(FE): change import module to absolute path 2025-12-01 10:22:00 +07:00
randy-ar 2ace95a0db feat(FE): add drawer ui store 2025-12-01 10:13:28 +07:00
randy-ar 892bb19dfd refactor(FE): change project flock form, detail and chickin view using drawer 2025-11-28 16:41:01 +07:00
rstubryan 7a76719547 refactor(FE-Storyless): remove console, window and err catch 2025-11-27 13:46:53 +07:00
rstubryan 4b6144d0b4 refactor(FE-Storyless): update import paths for schema files to use absolute paths 2025-11-27 13:36:12 +07:00
126 changed files with 8817 additions and 3688 deletions
+3
View File
@@ -42,3 +42,6 @@ next-env.d.ts
# idea # idea
.idea .idea
# claude
.claude
+31 -6
View File
@@ -73,8 +73,8 @@ stages:
if [ "$CI_COMMIT_BRANCH" = "development" ]; then if [ "$CI_COMMIT_BRANCH" = "development" ]; then
ENVIRONMENT_NAME="WEB-LTI-DEV" ENVIRONMENT_NAME="WEB-LTI-DEV"
elif [ "$CI_COMMIT_BRANCH" = "master" ]; then elif [ "$CI_COMMIT_BRANCH" = "staging" ]; then
ENVIRONMENT_NAME="WEB-LTI-PROD" ENVIRONMENT_NAME="WEB-LTI-STAGING"
else else
ENVIRONMENT_NAME="UNKNOWN" ENVIRONMENT_NAME="UNKNOWN"
fi fi
@@ -122,11 +122,10 @@ build:dev:
environment: environment:
name: development name: development
variables: variables:
# NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
# NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id' NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id' NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api' NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
deploy:dev: deploy:dev:
<<: *deploy_template <<: *deploy_template
@@ -140,7 +139,34 @@ deploy:dev:
environment: environment:
name: development name: development
url: https://dev-lti-erp.mbugroup.id url: https://dev-lti-erp.mbugroup.id
# ====== STAGING (Branch staging) ======
build:staging:
<<: *build_template
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
environment:
name: staging
variables:
NEXT_PUBLIC_LTI_URL: 'https://stg-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
deploy:staging:
<<: *deploy_template
needs: ['build:staging']
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
variables:
S3_BUCKET: 'stg-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E2V6PPO1AUIU7H'
environment:
name: staging
url: https://stg-lti-erp.mbugroup.id
# ====== PRODUCTION ====== # ====== PRODUCTION ======
# build:production: # build:production:
# <<: *build_template # <<: *build_template
@@ -163,4 +189,3 @@ deploy:dev:
# environment: # environment:
# name: production # name: production
+1
View File
@@ -3,6 +3,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'export', output: 'export',
images: { unoptimized: true }, images: { unoptimized: true },
trailingSlash: true,
}; };
export default nextConfig; export default nextConfig;
+12 -12
View File
@@ -36,9 +36,9 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"daisyui": "^5.1.12", "daisyui": "^5.5.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "^15.5.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
@@ -1088,9 +1088,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.7.tgz",
"integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==", "integrity": "sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3063,9 +3063,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "5.3.10", "version": "5.5.8",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.10.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.8.tgz",
"integrity": "sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==", "integrity": "sha512-6psL9jIEOFOw68V10j/BKCWcRgx8dh81mmNxShr+g7HDM6UHNoPharlp9zq/PQkHNuGU1ZQsajR3HgpvavbRKQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -3571,13 +3571,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.7.tgz",
"integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==", "integrity": "sha512-nU/TRGHHeG81NeLW5DeQT5t6BDUqbpsNQTvef1ld/tqHT+/zTx60/TIhKnmPISTTe++DVo+DLxDmk4rnwHaZVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "15.5.3", "@next/eslint-plugin-next": "15.5.7",
"@rushstack/eslint-patch": "^1.10.3", "@rushstack/eslint-patch": "^1.10.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+2 -2
View File
@@ -39,9 +39,9 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"daisyui": "^5.1.12", "daisyui": "^5.5.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "^15.5.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ClosingDetail from '@/components/pages/closing/ClosingDetail';
import { ClosingApi } from '@/services/api/closing';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ClosingDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const closingId = searchParams.get('closingId');
const { data: closing, isLoading: isLoadingClosing } = useSWR(
closingId,
(id: number) => ClosingApi.getGeneralInfo(id)
);
const { data: salesData, isLoading: isLoadingSales } = useSWR(
closingId ? `sales-${closingId}` : null,
() => ClosingApi.getPenjualan(Number(closingId))
);
if (!closingId) {
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 (!isLoadingClosing && (!closing || isResponseError(closing))) {
router.replace('/404');
return;
}
const isLoading = isLoadingClosing || isLoadingSales;
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(closing) && (
<ClosingDetail
id={Number(closingId)}
initialValue={closing.data}
salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
/>
)}
</div>
);
};
export default ClosingDetailPage;
+11
View File
@@ -0,0 +1,11 @@
import ClosingsTable from '@/components/pages/closing/ClosingsTable';
const Closing = () => {
return (
<section className='w-full p-4'>
<ClosingsTable />
</section>
);
};
export default Closing;
+39 -20
View File
@@ -7,26 +7,39 @@
default: false; default: false;
prefersdark: false; prefersdark: false;
color-scheme: 'light'; color-scheme: 'light';
--color-base-100: oklch(98% 0.001 106.423);
--color-base-200: oklch(97% 0.001 106.424); /* Primary Colors */
--color-base-300: oklch(92% 0.003 48.717); --color-primary: oklch(39.4% 0.177 301.9);
--color-base-content: oklch(22.389% 0.031 278.072); --color-primary-content: oklch(87.5% 0.038 274.5);
--color-primary: oklch(60% 0.126 221.723);
--color-primary-content: oklch(100% 0 0); /* Secondary Colors */
--color-secondary: oklch(52% 0.105 223.128); --color-secondary: oklch(60.1% 0.258 335.7);
--color-secondary-content: oklch(100% 0 0); --color-secondary-content: oklch(99.4% 0.007 337.8);
--color-accent: oklch(45% 0.085 224.283);
--color-accent-content: oklch(100% 0 0); /* Accent Colors */
--color-neutral: oklch(39% 0.07 227.392); --color-accent: oklch(76.2% 0.155 170.8);
--color-neutral-content: oklch(100% 0 0); --color-accent-content: oklch(7.2% 0.007 167.6);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(100% 0 0); /* Neutral Colors */
--color-success: oklch(62% 0.194 149.214); --color-neutral: oklch(22.4% 0.032 258.8);
--color-success-content: oklch(100% 0 0); --color-neutral-content: oklch(87.7% 0.016 257);
--color-warning: oklch(85% 0.199 91.936);
--color-warning-content: oklch(0% 0 0); /* Base Colors */
--color-error: oklch(57% 0.245 27.325); --color-base-100: oklch(100% 0 0); /* #ffffff */
--color-error-content: oklch(100% 0 0); --color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */
--color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */
--color-base-content: oklch(18.6% 0.024 257.7); /* #1f2937 */
/* Status/Utility Colors */
--color-info: oklch(67.4% 0.176 238.9);
--color-info-content: oklch(0% 0 0); /* #000000 */
--color-success: oklch(62.3% 0.147 149);
--color-success-content: oklch(100% 0 0); /* #ffffff */
--color-warning: oklch(82.2% 0.165 91.9);
--color-warning-content: oklch(0% 0 0); /* #000000 */
--color-error: oklch(61.8% 0.203 27.8);
--color-error-content: oklch(100% 0 0); /* #fffffff */
--radius-selector: 0rem; --radius-selector: 0rem;
--radius-field: 0.25rem; --radius-field: 0.25rem;
--radius-box: 0.25rem; --radius-box: 0.25rem;
@@ -43,6 +56,12 @@
@theme { @theme {
--font-inter: var(--font-inter); --font-inter: var(--font-inter);
--container-sm: 40rem;
--container-md: 48rem;
--container-lg: 64rem;
--container-xl: 80rem;
--container-2xl: 96rem;
} }
html { html {
@@ -12,8 +12,6 @@ const DetailInventoryAdjustment = () => {
// Ambil data dari router state // Ambil data dari router state
useEffect(() => { useEffect(() => {
console.log('Router State');
console.log(window.history.state);
const state = window.history.state?.usr as const state = window.history.state?.usr as
| { inventoryAdjustment?: InventoryAdjustment } | { inventoryAdjustment?: InventoryAdjustment }
| undefined; | undefined;
@@ -26,9 +24,6 @@ const DetailInventoryAdjustment = () => {
const finalData = inventoryAdjustment; const finalData = inventoryAdjustment;
console.log('Final Data');
console.log(finalData);
if (!finalData) { if (!finalData) {
return ( return (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+50
View File
@@ -0,0 +1,50 @@
'use client';
import InventoryProductDetail from '@/components/pages/inventory/product/detail/InventoryProductDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const InventoryProductDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const inventoryProductId = searchParams.get('inventoryProductId');
const { data: inventoryProduct, isLoading: isLoadingInventoryProduct } =
useSWR(inventoryProductId, (id: number) =>
InventoryProductApi.getSingle(id)
);
if (!inventoryProductId) {
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 (
!isLoadingInventoryProduct &&
(!inventoryProduct || isResponseError(inventoryProduct))
) {
router.replace('/404');
return;
}
return (
<div className='size-full'>
{isLoadingInventoryProduct && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingInventoryProduct && isResponseSuccess(inventoryProduct) && (
<InventoryProductDetail inventoryProduct={inventoryProduct.data} />
)}
</div>
);
};
export default InventoryProductDetailPage;
+11
View File
@@ -0,0 +1,11 @@
import InventoryProductTable from '@/components/pages/inventory/product/InventoryProductTable';
const InventoryProductPage = () => {
return (
<div className='size-full'>
<InventoryProductTable />
</div>
);
};
export default InventoryProductPage;
+1
View File
@@ -7,4 +7,5 @@ const Marketing = () => {
</div> </div>
); );
}; };
export default Marketing; export default Marketing;
+25 -7
View File
@@ -1,11 +1,29 @@
import { redirect } from 'next/navigation'; 'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/services/hooks/useAuth';
import { redirectToSSO } from '@/lib/auth-helper';
export default function Home() { export default function Home() {
redirect('/dashboard'); const { user, isLoadingUser } = useAuth();
return ( const router = useRouter();
<main className='w-full h-full min-h-screen flex flex-row justify-center items-center'> const pathname = usePathname();
<h1>LTI ERP</h1>
</main> useEffect(() => {
); if (pathname === '/') {
router.replace('/dashboard');
}
}, [pathname]);
if (isLoadingUser) {
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>
);
}
return <>Loading...</>;
} }
@@ -1,10 +1,18 @@
'use client'; 'use client';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import React, { useImperativeHandle } from 'react';
import toast from 'react-hot-toast';
const AddProjectFlock = () => { const AddProjectFlock = () => {
// useImperativeHandle(ref, () => ({
// validate() {
// toast.success('Validating');
// return false;
// },
// }));
return ( return (
<section className='w-full p-4 flex flex-row justify-center'> <section className='w-full flex flex-row justify-center'>
<ProjectFlockForm formType='add' /> <ProjectFlockForm formType='add' />
</section> </section>
); );
@@ -44,7 +44,7 @@ export default function AddChickinKandang() {
return ( return (
<> <>
<section className='w-full p-4'> <section className='size-full'>
{isLoading && <span className='loading loading-spinner loading-xl' />} {isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && {!isLoading &&
isResponseSuccess(projectFlockKandang) && isResponseSuccess(projectFlockKandang) &&
@@ -10,7 +10,7 @@ const AddChickin = () => {
return ( return (
<> <>
<section className='w-full p-4'> <section className='w-full'>
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} /> <ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
</section> </section>
</> </>
@@ -2,7 +2,7 @@ import ChickinTable from '@/components/pages/production/chickin/ChickinTable';
const Chickin = () => { const Chickin = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full'>
<ChickinTable /> <ChickinTable />
</section> </section>
); );
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,63 @@
'use client';
import ProjectFlockClosingForm from '@/components/pages/production/project-flock/closing/ProjectFlockClosingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const ProjectFlockClosingPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
const projectFlockKandangId = searchParams.get('projectFlockKandangId');
const { data: projectFlockKandang, isLoading: isLoadingProjectFlockKandang } =
useSWR(`get-flock-kandang-id/${projectFlockKandangId}`, () =>
ProjectFlockKandangApi.getSingle(parseInt(projectFlockKandangId ?? ''))
);
const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR(
`get-flock-id/${projectFlockId}`,
() => ProjectFlockApi.getSingle(parseInt(projectFlockId ?? ''))
);
if (!projectFlockId || !projectFlockKandangId) {
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 (
!isLoadingProjectFlock &&
(!projectFlock || isResponseError(projectFlock)) &&
!isLoadingProjectFlockKandang &&
(!projectFlockKandang || isResponseError(projectFlockKandang))
) {
router.replace('/404');
return;
}
return (
<div className='w-full h-full flex flex-col justify-center'>
{isLoadingProjectFlock ||
(isLoadingProjectFlockKandang && (
<span className='loading loading-spinner loading-xl' />
))}
{isResponseSuccess(projectFlock) &&
isResponseSuccess(projectFlockKandang) && (
<ProjectFlockClosingForm
projectFlock={projectFlock.data}
projectFlockKandang={projectFlockKandang.data}
/>
)}
</div>
);
};
export default ProjectFlockClosingPage;
@@ -37,7 +37,7 @@ const ProjectFlockEdit = () => {
} }
return ( return (
<div className='w-full p-4 flex flex-col justify-center'> <div className='w-full flex flex-col justify-center'>
{isLoadingProjectFlock && ( {isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
@@ -1,12 +1,13 @@
'use client'; 'use client';
import ProjectFlockDetail from '@/components/pages/production/project-flock/detail/ProjectFlockDetail';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
const ProjectFlockDetail = () => { const ProjectFlockDetailPage = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -37,19 +38,17 @@ const ProjectFlockDetail = () => {
} }
return ( return (
<div className='w-full p-4 flex flex-col justify-center'> <div className='w-full h-full flex flex-col justify-center'>
{isLoadingProjectFlock && ( {isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
{isResponseSuccess(projectFlock) && ( {isResponseSuccess(projectFlock) && (
<ProjectFlockForm <ProjectFlockDetail projectFlock={projectFlock.data} />
formType='detail'
initialValues={projectFlock.data}
refreshProjectFlocks={refreshProjectFlock}
/>
)} )}
</div> </div>
); );
}; };
export default ProjectFlockDetail; export default ProjectFlockDetailPage;
ProjectFlockDetail;
ProjectFlockDetail;
@@ -0,0 +1,60 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import Drawer from '@/components/Drawer';
import React, { ReactNode } from 'react';
import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable';
import { useUiStore } from '@/stores/ui/ui.store';
export default function ProjectFlockLayout({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const toggleValidate = useUiStore((s) => s.toggleValidate);
const isAdd = pathname.includes('/add');
const isEdit = pathname.includes('/detail/edit');
const isDetail = pathname.includes('/detail');
const isChickin = pathname.includes('/chickin/add/kandang');
const isClosing = pathname.includes('/closing');
const isOpen = isAdd || isEdit || isDetail || isChickin || isClosing;
const handleBackdropClick = () => {
const unsub = useUiStore.getState().subscribeIsValid((isValid) => {
if (isValid) {
unsub(); // berhenti listen
router.push('/production/project-flock');
}
});
toggleValidate();
};
return (
<>
{/* List page always rendered */}
<div className='min-h-sceen w-full relative'>
<ProjectFlockTable
refresh={() => !isOpen && router.push('/production/project-flock')}
/>
</div>
{/* Render Drawer only on /add */}
<Drawer
open={isOpen}
setOpen={(v) => {
if (!v) router.push('/production/project-flock');
}}
closeOnBackdropClick={isDetail ? true : false}
onBackdropClick={handleBackdropClick}
variant='right'
zIndex='99999'
sidebarContent={isOpen && <div className=''>{children}</div>}
/>
</>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import ProjectFlockTable from '@/components/pages/production/project-flock/Proje
const ProjectFlock = () => { const ProjectFlock = () => {
return ( return (
<section className='w-full p-4'> <section className='size-full p-4'>
<ProjectFlockTable /> <ProjectFlockTable />
</section> </section>
); );
@@ -14,7 +14,7 @@ const RecordingEdit = () => {
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId, recordingId,
(id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi (id: string) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
+1 -1
View File
@@ -14,7 +14,7 @@ const RecordingDetail = () => {
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId, recordingId,
(id: number) => RecordingApi.getSingle(id) (id: string) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
@@ -1,49 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const AddGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recording_id');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId && recordingId !== 'new' ? [recordingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (
recordingId &&
recordingId !== 'new' &&
!isLoadingRecording &&
(!recording || !isResponseSuccess(recording))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{recordingId && recordingId !== 'new' && isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{(!recordingId ||
recordingId === 'new' ||
(!isLoadingRecording && recording && isResponseSuccess(recording))) && (
<GradingForm
type='add'
initialValues={
isResponseSuccess(recording) ? recording.data?.eggs?.[0] : undefined
}
/>
)}
</div>
);
};
export default AddGrading;
@@ -1,53 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const EditGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const gradingId = searchParams.get('gradingId');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId ? [recordingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (!recordingId) {
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 (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && recording && isResponseSuccess(recording) && (
<GradingForm
type='edit'
initialValues={recording.data.eggs?.find(
(egg) => egg.id === parseInt(gradingId || '0')
)}
/>
)}
</div>
);
};
export default EditGrading;
@@ -1,52 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const DetailGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const gradingId = searchParams.get('gradingId');
const { data: grading, isLoading: isLoadingGrading } = useSWR(
gradingId ? [gradingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (!gradingId) {
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 (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingGrading && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingGrading && grading && isResponseSuccess(grading) && (
<GradingForm
type='detail'
initialValues={grading.data.eggs?.find(
(egg) => egg.id === parseInt(gradingId)
)}
/>
)}
</div>
);
};
export default DetailGrading;
+1 -1
View File
@@ -4,7 +4,7 @@ import { HTMLAttributes, ReactNode, useState } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import Image from 'next/image'; import Image from 'next/image';
import Collapse from './Collapse'; import Collapse from '@/components/Collapse';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
export interface CardProps export interface CardProps
+103 -8
View File
@@ -10,28 +10,102 @@ interface DrawerProps {
open: boolean; open: boolean;
setOpen: (newOpenState: boolean) => void; setOpen: (newOpenState: boolean) => void;
openOnLarge?: boolean; openOnLarge?: boolean;
variant?: 'sidebar' | 'left' | 'right';
zIndex?: string;
className?: DrawerClassName;
onBackdropClick?: () => void;
closeOnBackdropClick?: boolean;
} }
type DrawerClassName = {
drawer?: string;
drawerContent?: string;
drawerSide?: string;
drawerOverlay?: string;
drawerSidebarContent?: string;
};
const Drawer = ({ const Drawer = ({
children, children,
sidebarContent, sidebarContent,
open, open,
setOpen, setOpen,
openOnLarge, openOnLarge,
variant = 'sidebar',
zIndex = '20',
className,
onBackdropClick,
closeOnBackdropClick = true,
}: DrawerProps) => { }: DrawerProps) => {
const getDrawerClassNames = (): DrawerClassName => {
const baseClassNames = {
drawer: 'drawer',
drawerContent: 'drawer-content',
drawerSide: 'drawer-side',
drawerOverlay: 'drawer-overlay',
drawerSidebarContent: 'min-h-full bg-base-100',
};
if (variant === 'sidebar') {
return {
...baseClassNames,
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full max-w-[300px] lg:w-[300px]'
),
};
} else if (variant === 'right') {
return {
...baseClassNames,
drawer: cn(baseClassNames.drawer, 'drawer-end'),
drawerSide: cn(
baseClassNames.drawerSide,
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full min-w-120 sm:w-fit'
),
};
} else if (variant === 'left') {
return {
...baseClassNames,
drawerSide: cn(
baseClassNames.drawerSide,
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full min-w-120 sm:w-fit'
),
};
}
return baseClassNames; // Fallback for default or unknown variant
};
const varianClassName = getDrawerClassNames();
const toggleDrawer = () => { const toggleDrawer = () => {
setOpen(!open); setOpen(!open);
}; };
const closeDrawer = () => { const closeDrawer = () => {
setOpen(false); if (closeOnBackdropClick) {
setOpen(false);
}
onBackdropClick && onBackdropClick();
}; };
return ( return (
<div <div
className={cn('drawer', { className={cn(
'lg:drawer-open': openOnLarge, 'drawer',
})} {
'lg:drawer-open': openOnLarge,
},
varianClassName?.drawer,
className?.drawer
)}
> >
<input <input
type='checkbox' type='checkbox'
@@ -40,16 +114,37 @@ const Drawer = ({
className='drawer-toggle' className='drawer-toggle'
/> />
<div className='drawer-content'>{children}</div> {/* Drawer Content */}
<div
className={cn(varianClassName?.drawerContent, className?.drawerContent)}
>
{children}
</div>
<div className='drawer-side border-r border-solid border-gray-200 z-20'> {/* Drawer Side */}
<div
className={cn(
varianClassName?.drawerSide,
className?.drawerSide,
zIndex
)}
>
<label <label
aria-label='close sidebar' aria-label='close sidebar'
className='drawer-overlay' className={cn(
varianClassName?.drawerOverlay,
className?.drawerOverlay
)}
onClick={closeDrawer} onClick={closeDrawer}
/> />
<div className='min-h-full w-full max-w-[300px] lg:w-[300px] bg-base-100'> {/* Sidebar Content */}
<div
className={cn(
varianClassName?.drawerSidebarContent,
className?.drawerContent
)}
>
{sidebarContent} {sidebarContent}
</div> </div>
</div> </div>
+141
View File
@@ -0,0 +1,141 @@
'use client';
import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
type FloatingActionsButtonProps = {
actions: {
action: 'DETAIL' | 'EDIT' | 'DELETE';
icon: string;
label?: string;
onClick?: () => void;
hidden?: boolean;
disabled?: boolean;
}[];
approvals: {
action: 'APPROVED' | 'REJECTED';
icon: string;
label?: string;
onClick?: () => void;
disabled?: boolean;
}[];
selectedRowIds: number[];
onClose: () => void;
};
const FloatingActionsButton = ({
actions,
approvals,
selectedRowIds,
onClose,
}: FloatingActionsButtonProps) => {
// Jika tidak ada baris yang dipilih, jangan tampilkan FAB
const positionStyles =
selectedRowIds.length > 0 ? 'bottom-[10%]' : 'bottom-[-100%]';
// Helper untuk menentukan gaya warna tombol approval
const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => {
if (action === 'APPROVED') return 'success';
if (action === 'REJECTED') return 'error';
return 'primary';
};
const getActionColor = (action: 'DETAIL' | 'EDIT' | 'DELETE') => {
if (action === 'DETAIL') return 'white';
if (action === 'EDIT') return 'warning';
if (action === 'DELETE') return 'error';
return 'primary';
};
return (
// Container utama FAB
<div
className={cn(
`absolute ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`,
'mx-auto w-full max-w-sm sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
'bg-slate-950 backdrop-blur-md'
)}
>
<div className='flex flex-col gap-3'>
{/* === BARIS ATAS: Status Seleksi dan Actions (Termasuk Close) === */}
<div className='flex justify-between items-center text-white'>
<h4 className='text-base font-semibold'>
{selectedRowIds.length} Selected
</h4>
<div className='flex flex-row gap-1 items-stretch'>
<div className='flex gap-4 items-center'>
{/* Render Aksi dari props.actions */}
{actions
.filter((action) => !action.hidden)
.map((action, index) => {
return (
<Button
key={index}
onClick={action.onClick}
className='text-white hover:text-gray-400 tooltip tooltip-bottom p-0'
variant='link'
disabled={action.disabled}
>
<Tooltip content={action.label || action.action}>
<Icon
icon={action.icon}
width={20}
height={20}
className={`text-${getActionColor(action.action)} font-thin`}
/>
</Tooltip>
</Button>
);
})}
<div className='border-[0.5px] border-white/30 h-full'></div>
{/* Tombol Close */}
<Button
onClick={onClose}
className='text-white hover:text-gray-400 p-0'
variant='link'
>
<Tooltip content='Close'>
<Icon icon='mdi:close' width={20} height={20} />
</Tooltip>
</Button>
</div>
</div>
</div>
{/* === BARIS BAWAH: Approval Buttons (Approve/Reject) === */}
<div className={`grid grid-cols-${approvals.length} gap-3`}>
{approvals.map((approval, index) => (
<Button
key={index}
onClick={approval.onClick}
className={cn(
'btn btn-lg w-full',
'bg-white/20 border-white/30',
'text-white/50 font-semibold flex items-center gap-2 rounded-lg transition-all duration-200',
approval.disabled
? 'cursor-not-allowed'
: 'hover:text-white/100 hover:bg-white/40 hover:border-white/50'
)}
disabled={approval.disabled}
>
<Icon
icon={approval.icon}
width={20}
height={20}
className={`text-${getApprovalColor(approval.action)}`}
/>
{approval.label || approval.action}
</Button>
))}
</div>
</div>
</div>
);
};
export default FloatingActionsButton;
+7 -147
View File
@@ -1,161 +1,21 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Drawer from '@/components/Drawer'; import Drawer from '@/components/Drawer';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import Collapse from '@/components/Collapse';
import Button from '@/components/Button'; import Button from '@/components/Button';
import SidebarMenu from '@/components/molecules/SidebarMenu';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { MAIN_DRAWER_LINKS } from '@/config/constant'; import { MAIN_DRAWER_LINKS } from '@/config/constant';
import { cn } from '@/lib/helper'; import { isPathActive } from '@/lib/helper';
type CollapseMenuProps = {
title: string;
link: string;
icon: string;
submenu?: CollapseMenuProps[];
depth?: number;
};
const isPathActive = (pathname: string, link?: string) => {
if (!link) return false;
const splittedPathname = pathname.split('/');
const splittedLink = link.split('/');
const isActiveLinkValid = splittedLink.every((linkChunk, idx) => {
return linkChunk === splittedPathname[idx];
});
return pathname.startsWith(link) && isActiveLinkValid;
};
const CollapseMenu = ({
title,
link,
icon,
submenu,
depth = 0,
}: CollapseMenuProps) => {
const pathname = usePathname();
const isActive = isPathActive(pathname, link);
const [open, setOpen] = useState(isActive);
const menuCollapseTitle = (
<div
className={cn(
'w-full px-3 py-2 rounded-md text-base font-semibold transition-colors flex flex-row justify-between items-center gap-2 hover:bg-primary/10 opacity-40',
{
'bg-primary/10 opacity-100': open || isActive,
}
)}
>
<div className='flex flex-row items-center gap-2'>
<Icon icon={icon} width={20} height={20} />
<span>{title}</span>
</div>
<Icon
icon='cuida:caret-up-outline'
width={20}
height={20}
className={cn('transition-transform', {
'rotate-90': !open,
'rotate-180': open,
})}
/>
</div>
);
return (
<Collapse
open={open}
title={menuCollapseTitle}
onOpenChange={setOpen}
className='w-full'
titleClassName='w-full p-0!'
>
<Menu>
<div
className='w-full py-0.5 flex flex-col gap-0.5'
style={{
paddingLeft: `${0.5 * (depth + 1)}rem`,
}}
>
{submenu?.map((item, idx) => {
const hasSubmenu = item.submenu && item.submenu.length > 0;
if (!hasSubmenu) {
return (
<MenuItem
key={idx}
title={item.title}
href={item.link}
icon={item.icon}
active={isPathActive(pathname, item.link)}
/>
);
}
return (
<CollapseMenu
key={idx}
title={item.title}
link={item.link}
icon={item.icon}
submenu={item.submenu}
depth={depth + 1}
/>
);
})}
</div>
</Menu>
</Collapse>
);
};
const MainDrawerMenu = () => {
const pathname = usePathname();
return (
<Menu>
{MAIN_DRAWER_LINKS.map((item, idx) => {
const hasSubmenu = item.submenu && item.submenu.length > 0;
if (!hasSubmenu) {
return (
<MenuItem
key={idx}
title={item.title}
href={item.link}
icon={item.icon}
active={pathname.startsWith(item.link)}
/>
);
}
return (
<CollapseMenu
key={idx}
title={item.title}
link={item.link}
icon={item.icon}
submenu={item.submenu}
/>
);
})}
</Menu>
);
};
const MainDrawerContent = () => { const MainDrawerContent = () => {
const pathname = usePathname();
const { setMainDrawerOpen } = useUiStore(); const { setMainDrawerOpen } = useUiStore();
const closeMainDrawerHandler = () => { const closeMainDrawerHandler = () => {
@@ -191,7 +51,7 @@ const MainDrawerContent = () => {
</div> </div>
</div> </div>
<MainDrawerMenu /> <SidebarMenu menu={MAIN_DRAWER_LINKS} activeLink={pathname} />
</div> </div>
); );
}; };
@@ -216,9 +76,9 @@ const MainDrawer = ({
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0; const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
if (!title) { if (!title) {
title += menu?.title; title += menu?.text;
} else { } else {
title += ' - ' + menu?.title; title += ' - ' + menu?.text;
} }
if (!hasSubmenu || !menu.submenu) return; if (!hasSubmenu || !menu.submenu) return;
+13 -12
View File
@@ -7,6 +7,7 @@ import { Icon } from '@iconify/react';
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 Button from '@/components/Button'; import Button from '@/components/Button';
import Dropdown from '@/components/dropdown/Dropdown';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth'; import { AuthApi } from '@/services/api/auth';
@@ -52,21 +53,21 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<div className='dropdown dropdown-end'> <Dropdown
<div position='bottom-end'
tabIndex={0} trigger={
role='button' <div className='btn btn-ghost btn-circle avatar'>
className='btn btn-ghost btn-circle avatar' <div className='w-10 rounded-full border flex justify-center items-center'>
> <Icon icon='uil:user' width={40} height={40} />
<div className='w-10 rounded-full border grid place-items-center'> </div>
<Icon icon='uil:user' width={40} height={40} />
</div> </div>
</div> }
contentClassName='w-52 mt-3'
<Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'> >
<Menu className='p-2 bg-base-100 shadow rounded-box menu-sm'>
<MenuItem title='Logout' onClick={logoutClickHandler} /> <MenuItem title='Logout' onClick={logoutClickHandler} />
</Menu> </Menu>
</div> </Dropdown>
</div> </div>
</div> </div>
); );
+302 -212
View File
@@ -1,7 +1,9 @@
'use client'; 'use client';
import { ReactNode } from 'react'; import { ChangeEventHandler, ReactNode } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
@@ -17,16 +19,18 @@ const PaginationButton = ({
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
}) => ( }) => (
<button <Button
className={cn( variant='ghost'
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square', color='none'
'disabled:text-gray-700 disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-gray-50 disabled:active:translate-y-0'
)}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:text-primary disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-primary/10 disabled:active:translate-y-0'
)}
> >
{content} {content}
</button> </Button>
); );
const EtcPaginationButton = ({ const EtcPaginationButton = ({
@@ -48,7 +52,7 @@ const EtcPaginationButton = ({
tabIndex={0} tabIndex={0}
role='button' role='button'
className={cn( className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square' 'join-item btn btn-ghost p-2.5 rounded-lg! text-sm font-medium text-gray-500 aspect-square'
)} )}
> >
... ...
@@ -57,7 +61,7 @@ const EtcPaginationButton = ({
<div className='dropdown-content'> <div className='dropdown-content'>
<ul <ul
tabIndex={0} tabIndex={0}
className='menu bg-base-100 rounded-lg z-1 w-fit min-w-max max-h-64 p-1 shadow-sm mb-2 overflow-y-auto flex-nowrap' className='menu bg-base-100 rounded-lg! z-1 w-fit min-w-max max-h-64 p-1 shadow-sm mb-2 overflow-y-auto flex-nowrap'
> >
{pages.map((pageNumber) => ( {pages.map((pageNumber) => (
<li key={pageNumber}> <li key={pageNumber}>
@@ -76,7 +80,7 @@ const EtcPaginationButton = ({
<button <button
disabled disabled
className={cn( className={cn(
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square' 'join-item btn btn-ghost p-2.5 rounded-lg! text-sm font-medium text-gray-500 aspect-square'
)} )}
> >
... ...
@@ -90,16 +94,20 @@ const Pagination = ({
currentPage = 1, currentPage = 1,
totalItems = 0, totalItems = 0,
itemsPerPage = 10, itemsPerPage = 10,
rowOptions = [10, 20, 50, 100],
onPageChange, onPageChange,
onPrevPage = () => {}, onPrevPage = () => {},
onNextPage = () => {}, onNextPage = () => {},
onRowChange,
}: { }: {
currentPage: number; currentPage: number;
totalItems: number; totalItems: number;
itemsPerPage: number; itemsPerPage: number;
rowOptions?: number[];
onPageChange: (pageNumber: number) => void; onPageChange: (pageNumber: number) => void;
onPrevPage: () => void; onPrevPage: () => void;
onNextPage: () => void; onNextPage: () => void;
onRowChange?: (row: number) => void;
}) => { }) => {
const totalPages = const totalPages =
Math.ceil(totalItems / itemsPerPage) === 0 Math.ceil(totalItems / itemsPerPage) === 0
@@ -107,30 +115,139 @@ const Pagination = ({
: Math.ceil(totalItems / itemsPerPage); : Math.ceil(totalItems / itemsPerPage);
const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber); const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber);
const firstPageClickHandler = () => onPageChange(1);
const lastPageClickHandler = () => onPageChange(totalPages);
const rowChangeHandler: ChangeEventHandler<HTMLSelectElement> = (e) => {
onRowChange?.(Number(e.target.value));
};
const DisplayedRowCountSelect = () => (
<div className='flex flex-row items-center gap-4'>
<span className='text-sm font-medium text-base-content/50'>Showing</span>
<select
defaultValue={itemsPerPage}
onChange={rowChangeHandler}
className='select select-xs w-fit pl-3 pr-7 text-base-content/50'
>
{rowOptions.map((rowOption, rowOptionIdx) => (
<option
key={rowOptionIdx}
value={rowOption}
className='text-base-content active:text-neutral-content'
>
{rowOption} Per page
</option>
))}
</select>
</div>
);
const GoToFirstPageButton = () => (
<Button
disabled={currentPage === 1}
onClick={firstPageClickHandler}
variant='ghost'
color='none'
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-double-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const PrevPageButton = () => (
<Button
disabled={currentPage === 1}
onClick={onPrevPage}
variant='ghost'
color='none'
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const GoToLastPageButton = () => (
<Button
variant='ghost'
color='none'
disabled={currentPage === totalPages}
onClick={lastPageClickHandler}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-double-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const NextPageButton = () => (
<Button
variant='ghost'
color='none'
disabled={currentPage === totalPages}
onClick={onNextPage}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'>
Page {currentPage} of {totalPages}
</span>
);
return ( return (
<div> <div className='@container'>
<div className='join w-full justify-between items-center gap-3'> <div className='flex flex-row justify-center items-center'>
<button <div className='hidden @md:block'>
disabled={currentPage === 1} <DisplayedRowCountSelect />
onClick={onPrevPage} </div>
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='uil:arrow-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>{' '}
Previous
</button>
{totalPages <= 7 && ( <div className='join w-full justify-end @md:justify-center items-center gap-0.5'>
<div className='join-item join gap-0.5'> <div className='hidden @md:block'>
{range(1, totalPages).map((pageNumber) => ( <GoToFirstPageButton />
</div>
<div className='hidden @md:block'>
<PrevPageButton />
</div>
{totalPages <= 7 &&
range(1, totalPages).map((pageNumber) => (
<PaginationButton <PaginationButton
key={pageNumber} key={pageNumber}
content={pageNumber} content={pageNumber}
@@ -138,195 +255,168 @@ const Pagination = ({
onClick={() => pageChangeHandler(pageNumber)} onClick={() => pageChangeHandler(pageNumber)}
/> />
))} ))}
</div>
)}
{totalPages > 7 && ( {totalPages > 7 && (
<div className='join-item join gap-0.5'> <>
<PaginationButton
content={1}
disabled={currentPage === 1}
onClick={() => pageChangeHandler(1)}
/>
{totalPages >= 2 &&
(currentPage <= 3 || currentPage >= totalPages - 2) && (
<PaginationButton
content={2}
disabled={currentPage === 2}
onClick={() => pageChangeHandler(2)}
/>
)}
{totalPages >= 2 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<EtcPaginationButton
startPage={2}
endPage={currentPage - 2}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
(currentPage <= 4 || currentPage >= totalPages - 2) &&
currentPage !== totalPages - 2 && (
<PaginationButton
content={3}
disabled={currentPage === 3}
onClick={() => pageChangeHandler(3)}
/>
)}
{totalPages >= 7 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<EtcPaginationButton
startPage={
currentPage <= 2
? currentPage + 2
: currentPage === totalPages - 2
? 3
: currentPage >= totalPages - 1
? 4
: 1
}
endPage={
currentPage <= 2 || currentPage >= totalPages - 1
? totalPages - 3
: currentPage === totalPages - 2
? totalPages - 4
: 2
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
currentPage > 4 &&
currentPage < totalPages - 1 && (
<PaginationButton
content={currentPage - 1}
onClick={() => pageChangeHandler(currentPage - 1)}
/>
)}
{totalPages >= 7 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<PaginationButton content={currentPage} disabled />
)}
{totalPages >= 5 &&
currentPage > 2 &&
currentPage < totalPages - 2 && (
<PaginationButton
content={currentPage + 1}
onClick={() => pageChangeHandler(currentPage + 1)}
/>
)}
{totalPages >= 5 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<PaginationButton
content={totalPages - 2}
disabled={currentPage === totalPages - 2}
onClick={() => pageChangeHandler(totalPages - 2)}
/>
)}
{totalPages >= 6 &&
currentPage > 2 &&
currentPage < totalPages - 3 && (
<EtcPaginationButton
startPage={
currentPage <= 3
? currentPage + 2
: currentPage >= 4
? currentPage + 2
: 1
}
endPage={
currentPage <= 3
? totalPages - 2
: currentPage >= 4
? totalPages - 1
: 0
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 6 &&
(currentPage <= 3 || currentPage >= totalPages - 3) && (
<PaginationButton
content={totalPages - 1}
disabled={currentPage === totalPages - 1}
onClick={() => pageChangeHandler(totalPages - 1)}
/>
)}
{totalPages >= 7 && (
<PaginationButton <PaginationButton
content={totalPages} content={1}
disabled={currentPage === totalPages} disabled={currentPage === 1}
onClick={() => pageChangeHandler(totalPages)} onClick={() => pageChangeHandler(1)}
/> />
)}
</div>
)}
<button {totalPages >= 2 &&
disabled={currentPage === totalPages} (currentPage <= 3 || currentPage >= totalPages - 2) && (
onClick={onNextPage} <PaginationButton
className={cn( content={2}
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5', disabled={currentPage === 2}
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0' onClick={() => pageChangeHandler(2)}
/>
)}
{totalPages >= 2 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<EtcPaginationButton
startPage={2}
endPage={currentPage - 2}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
(currentPage <= 4 || currentPage >= totalPages - 2) &&
currentPage !== totalPages - 2 && (
<PaginationButton
content={3}
disabled={currentPage === 3}
onClick={() => pageChangeHandler(3)}
/>
)}
{totalPages >= 7 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<EtcPaginationButton
startPage={
currentPage <= 2
? currentPage + 2
: currentPage === totalPages - 2
? 3
: currentPage >= totalPages - 1
? 4
: 1
}
endPage={
currentPage <= 2 || currentPage >= totalPages - 1
? totalPages - 3
: currentPage === totalPages - 2
? totalPages - 4
: 2
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 3 &&
currentPage > 4 &&
currentPage < totalPages - 1 && (
<PaginationButton
content={currentPage - 1}
onClick={() => pageChangeHandler(currentPage - 1)}
/>
)}
{totalPages >= 7 &&
currentPage > 3 &&
currentPage < totalPages - 2 && (
<PaginationButton content={currentPage} disabled />
)}
{totalPages >= 5 &&
currentPage > 2 &&
currentPage < totalPages - 2 && (
<PaginationButton
content={currentPage + 1}
onClick={() => pageChangeHandler(currentPage + 1)}
/>
)}
{totalPages >= 5 &&
(currentPage <= 2 || currentPage >= totalPages - 2) && (
<PaginationButton
content={totalPages - 2}
disabled={currentPage === totalPages - 2}
onClick={() => pageChangeHandler(totalPages - 2)}
/>
)}
{totalPages >= 6 &&
currentPage > 2 &&
currentPage < totalPages - 3 && (
<EtcPaginationButton
startPage={
currentPage <= 3
? currentPage + 2
: currentPage >= 4
? currentPage + 2
: 1
}
endPage={
currentPage <= 3
? totalPages - 2
: currentPage >= 4
? totalPages - 1
: 0
}
onPageItemClick={pageChangeHandler}
/>
)}
{totalPages >= 6 &&
(currentPage <= 3 || currentPage >= totalPages - 3) && (
<PaginationButton
content={totalPages - 1}
disabled={currentPage === totalPages - 1}
onClick={() => pageChangeHandler(totalPages - 1)}
/>
)}
{totalPages >= 7 && (
<PaginationButton
content={totalPages}
disabled={currentPage === totalPages}
onClick={() => pageChangeHandler(totalPages)}
/>
)}
</>
)} )}
>
Next{' '} <div className='hidden @md:block'>
<Icon <NextPageButton />
icon='uil:arrow-right' </div>
width={20}
height={20} <div className='hidden @md:block'>
className='text-gray-400 group-disabled:text-gray-300' <GoToLastPageButton />
/> </div>
</button> </div>
<div className='hidden @md:block'>
<PageInfo />
</div>
</div> </div>
<div className='flex gap-2 mt-2 sm:hidden'> <div className='flex @md:hidden flex-col justify-center items-end gap-2'>
<button <div className='flex flex-row items-center gap-0.5'>
disabled={currentPage === 1} <GoToFirstPageButton />
onClick={onPrevPage} <PrevPageButton />
className={cn( <NextPageButton />
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5', <GoToLastPageButton />
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0' </div>
)}
>
<Icon
icon='uil:arrow-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>{' '}
Previous
</button>
<button <div className='flex flex-row items-center gap-4'>
disabled={currentPage === totalPages} <DisplayedRowCountSelect />
onClick={onNextPage}
className={cn( <PageInfo />
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5', </div>
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
Next{' '}
<Icon
icon='uil:arrow-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</button>
</div> </div>
</div> </div>
); );
+152 -66
View File
@@ -14,6 +14,7 @@ import {
SortingState, SortingState,
OnChangeFn, OnChangeFn,
Row, Row,
HeaderContext,
} 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,6 +32,9 @@ interface TableClassNames {
tableBodyClassName?: string; tableBodyClassName?: string;
bodyRowClassName?: string; bodyRowClassName?: string;
bodyColumnClassName?: string; bodyColumnClassName?: string;
tableFooterClassName?: string;
footerRowClassName?: string;
footerColumnClassName?: string;
paginationClassName?: string; paginationClassName?: string;
} }
@@ -38,6 +42,7 @@ export interface TableProps<TData extends object> {
data: TData[]; data: TData[];
columns: ColumnDef<TData, unknown>[]; columns: ColumnDef<TData, unknown>[];
pageSize?: number; pageSize?: number;
onPageSizeChange?: (pageSize: number) => void;
totalItems?: number; totalItems?: number;
page?: number; page?: number;
onPageChange?: (page: number) => void; onPageChange?: (page: number) => void;
@@ -52,6 +57,9 @@ export interface TableProps<TData extends object> {
rowSelection?: Record<string, boolean>; rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>; setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean); enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
renderFooter?: boolean;
withCheckbox?: boolean;
rowOptions?: number[];
} }
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -64,28 +72,36 @@ const emptyContentDefaultValue = (
</div> </div>
); );
export const TABLE_DEFAULT_STYLING = {
containerClassName: 'w-full mb-20',
tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-lg',
tableClassName: 'font-inter w-full table-auto text-sm font-medium',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName:
'px-4 py-3 border-base-content/10 text-base-content/50',
tableBodyClassName: '',
bodyRowClassName: 'border-t border-base-content/10',
bodyColumnClassName: 'px-4 py-3 text-base-content',
paginationClassName: '',
tableFooterClassName: 'font-semibold border-base-content/10',
footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10',
footerColumnClassName: 'p-4 text-base-content whitespace-nowrap',
};
const Table = <TData extends object>({ const Table = <TData extends object>({
data = [], data = [],
columns = [], columns = [],
pageSize = 10, pageSize = 10,
onPageSizeChange,
totalItems, totalItems,
page, page,
onPageChange, onPageChange,
isLoading = false, isLoading = false,
fuzzySearchValue, fuzzySearchValue,
onFuzzySearchValueChange, onFuzzySearchValueChange,
className = { className = TABLE_DEFAULT_STYLING,
containerClassName: '',
tableWrapperClassName: '',
tableClassName: '',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName: '',
tableBodyClassName: '',
bodyRowClassName: '',
bodyColumnClassName: '',
paginationClassName: '',
},
emptyContent = emptyContentDefaultValue, emptyContent = emptyContentDefaultValue,
sorting, sorting,
setSorting, setSorting,
@@ -93,12 +109,20 @@ const Table = <TData extends object>({
rowSelection, rowSelection,
setRowSelection, setRowSelection,
enableRowSelection, enableRowSelection,
renderFooter = false,
withCheckbox = false,
rowOptions = [10, 20, 50, 100],
}: TableProps<TData>) => { }: TableProps<TData>) => {
const isServerSideTable = const isServerSideTable =
totalItems !== undefined && totalItems !== undefined &&
page !== undefined && page !== undefined &&
onPageChange !== undefined; onPageChange !== undefined;
const tableClassNames = {
...TABLE_DEFAULT_STYLING,
...className,
};
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
pageSize: pageSize, pageSize: pageSize,
@@ -191,68 +215,106 @@ const Table = <TData extends object>({
}, [pageSize, setPageSize]); }, [pageSize, setPageSize]);
return ( return (
<div className={className.containerClassName}> <div className={tableClassNames.containerClassName}>
<div className={className.tableWrapperClassName}> <div className={tableClassNames.tableWrapperClassName}>
<table className={className.tableClassName}> <table className={tableClassNames.tableClassName}>
<thead className={className.tableHeaderClassName}> <thead className={tableClassNames.tableHeaderClassName}>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className={className.headerRowClassName}> <tr
{headerGroup.headers.map((header) => ( key={headerGroup.id}
<th className={tableClassNames.headerRowClassName}
key={header.id} >
colSpan={header.colSpan} {headerGroup.headers.map((header) => {
onClick={header.column.getToggleSortingHandler()} const columnRelativeDepth =
className={cn( header.depth - header.column.depth;
header.column.getCanSort() if (
? 'cursor-pointer select-none' !header.isPlaceholder &&
: '', columnRelativeDepth > 1 &&
className.headerColumnClassName header.id === header.column.id
)} ) {
> return null;
<div className='flex items-center gap-1'> }
{flexRender( let rowSpan = 1;
header.column.columnDef.header, if (header.isPlaceholder) {
header.getContext() const leafs = header.getLeafHeaders();
rowSpan = leafs[leafs.length - 1].depth - header.depth;
}
return (
<th
key={header.id}
colSpan={header.colSpan}
rowSpan={rowSpan}
onClick={header.column.getToggleSortingHandler()}
className={cn(
header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
{
'first:w-9 first:pr-0': withCheckbox,
},
{
'border-b': header.colSpan > 1,
},
tableClassNames.headerColumnClassName
)} )}
>
<div
className={cn('flex items-center gap-1 min-h-full', {
'justify-center': header.colSpan > 1,
})}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() && ( {header.column.getCanSort() && (
<div className='flex items-center'> <div className='w-4 h-4 relative flex flex-col items-center'>
<Icon <Icon
icon='lucide:arrow-up' icon='heroicons:chevron-up-16-solid'
width={12} width={18}
height={12} height={18}
className={cn( className={cn(
'transition-all ease-in-out duration-200', 'absolute -top-1',
header.column.getIsSorted() === 'asc' 'transition-all ease-in-out duration-200',
? 'text-black' header.column.getIsSorted() === 'asc'
: 'text-black/30' ? 'text-black'
)} : 'text-black/30'
/> )}
<Icon />
icon='lucide:arrow-down' <Icon
width={12} icon='heroicons:chevron-down-16-solid'
height={12} width={18}
className={cn( height={18}
'transition-all ease-in-out duration-200', className={cn(
header.column.getIsSorted() === 'desc' 'absolute -bottom-1.5',
? 'text-black' 'transition-all ease-in-out duration-200',
: 'text-black/30' header.column.getIsSorted() === 'desc'
)} ? 'text-black'
/> : 'text-black/30'
</div> )}
)} />
</div> </div>
</th> )}
))} </div>
</th>
);
})}
</tr> </tr>
))} ))}
</thead> </thead>
<tbody className={className.tableBodyClassName}> <tbody className={tableClassNames.tableBodyClassName}>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr key={row.id} className={className.bodyRowClassName}> <tr key={row.id} className={tableClassNames.bodyRowClassName}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className={className.bodyColumnClassName}> <td
key={cell.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.bodyColumnClassName
)}
>
{!isLoading && {!isLoading &&
flexRender(cell.column.columnDef.cell, cell.getContext())} flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -262,6 +324,28 @@ const Table = <TData extends object>({
</tr> </tr>
))} ))}
</tbody> </tbody>
<tfoot className={cn(tableClassNames.tableFooterClassName)}>
{renderFooter && (
<tr className={cn(tableClassNames.footerRowClassName)}>
{table.getAllLeafColumns().map((column) => (
<td
key={column.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.footerColumnClassName
)}
>
{column.columnDef.footer &&
flexRender(column.columnDef.footer, {
column,
header: column.columnDef,
table,
} as HeaderContext<TData, unknown>)}
</td>
))}
</tr>
)}
</tfoot>
</table> </table>
</div> </div>
@@ -270,7 +354,7 @@ const Table = <TData extends object>({
emptyContent} emptyContent}
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && ( {data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
<div className={cn('mt-5', className.paginationClassName)}> <div className={cn('mt-5', tableClassNames.paginationClassName)}>
<Pagination <Pagination
totalItems={isServerSideTable ? totalItems : table.getRowCount()} totalItems={isServerSideTable ? totalItems : table.getRowCount()}
itemsPerPage={table.getState().pagination.pageSize} itemsPerPage={table.getState().pagination.pageSize}
@@ -282,6 +366,8 @@ const Table = <TData extends object>({
onPrevPage={prevPageClickHandler} onPrevPage={prevPageClickHandler}
onNextPage={nextPageClickHandler} onNextPage={nextPageClickHandler}
onPageChange={pageChangeHandler} onPageChange={pageChangeHandler}
rowOptions={rowOptions}
onRowChange={onPageSizeChange}
/> />
</div> </div>
)} )}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { ReactNode, useRef, useEffect, useState } from 'react';
import { cn } from '@/lib/helper';
interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
position?:
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-start'
| 'top-end'
| 'bottom-start'
| 'bottom-end'
| 'left-start'
| 'left-end'
| 'right-start'
| 'right-end';
align?: 'start' | 'center' | 'end';
hover?: boolean;
className?: string;
contentClassName?: string;
}
const Dropdown = ({
trigger,
children,
position = 'bottom',
align = 'start',
hover = false,
className,
contentClassName,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Build position classes
const getPositionClasses = () => {
const classes: string[] = [];
// Handle combined positions like 'top-start'
if (position.includes('-')) {
const [pos, al] = position.split('-');
classes.push(`dropdown-${pos}`);
classes.push(`dropdown-${al}`);
} else {
classes.push(`dropdown-${position}`);
if (align !== 'start') {
classes.push(`dropdown-${align}`);
}
}
return classes.join(' ');
};
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// alert('clicked');
setIsOpen(!isOpen);
};
return (
<div
ref={dropdownRef}
className={cn(
'dropdown',
getPositionClasses(),
hover && 'dropdown-hover',
isOpen && 'dropdown-open',
className
)}
>
{/* Trigger Button */}
<div onClick={handleToggle} className='cursor-pointer'>
{trigger}
</div>
{/* Dropdown Content - Only render when open */}
{isOpen && (
<div
tabIndex={-1}
className={cn('dropdown-content z-[10]', contentClassName)}
onClick={() => setIsOpen(false)} // Close on item click
>
{children}
</div>
)}
</div>
);
};
export default Dropdown;
+45 -22
View File
@@ -1,56 +1,61 @@
'use client'; 'use client';
import { ReactNode, useEffect } from 'react'; import { ReactNode, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import useSWR from 'swr';
import useSWRImmutable from 'swr/immutable';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general'; import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { redirectToSSO } from '@/lib/auth-helper';
interface RequireAuthProps { interface RequireAuthProps {
children?: ReactNode; children?: ReactNode;
} }
const RequireAuth = ({ children }: RequireAuthProps) => { const RequireAuth = ({ children }: RequireAuthProps) => {
const router = useRouter(); const { user, setUser, setIsLoadingUser } = useAuth();
const { setUser, setIsLoadingUser } = useAuth();
const { const {
data: userResponse, data: userResponse,
isLoading: isLoadingUserResponse, isLoading: isLoadingUserResponse,
error: userErrorResponse, error: userErrorResponse,
} = useSWRImmutable< } = useSWR<
GetMeResponse & { ok?: boolean }, GetMeResponse & { ok?: boolean },
AxiosError<BaseApiResponse>, AxiosError<BaseApiResponse>,
SWRHttpKey SWRHttpKey
>('/sso/userinfo', httpClientFetcher, { >('/sso/userinfo', httpClientFetcher, {
shouldRetryOnError: false, shouldRetryOnError: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 0,
}); });
useEffect(() => {
setIsLoadingUser(isLoadingUserResponse);
}, [isLoadingUserResponse, setIsLoadingUser]);
useEffect(() => { useEffect(() => {
if (isResponseSuccess(userResponse)) { if (isResponseSuccess(userResponse)) {
setUser(userResponse.data); setUser(userResponse.data);
} else if (
isResponseError(userErrorResponse?.response?.data) &&
typeof window !== 'undefined'
) {
router.replace(
`${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`
);
} }
}, [userResponse, userErrorResponse, setIsLoadingUser, setUser]); }, [userResponse, setUser]);
if (isLoadingUserResponse && !userResponse && !userErrorResponse) { // Explicitly handle 401 redirect from the component level
useEffect(() => {
if (
isResponseError(userResponse) &&
userErrorResponse?.response?.status === 401
) {
// Clear cache to prevent stale data from rendering children
// mutate('/sso/userinfo', undefined, { revalidate: false }); // Optional: if using global mutate
setUser(undefined);
redirectToSSO();
}
}, [userErrorResponse, setUser, userResponse]);
useEffect(() => {
setIsLoadingUser(isLoadingUserResponse);
}, [isLoadingUserResponse]);
if (
(isLoadingUserResponse && !userResponse && !userErrorResponse) ||
(!userResponse && !userErrorResponse)
) {
return ( return (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
@@ -58,7 +63,25 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
); );
} }
return <>{isResponseSuccess(userResponse) && children}</>; if (userErrorResponse) {
return (
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
<h2 className='text-2xl font-bold text-error'>Authentication Failed</h2>
<p className='text-gray-600'>
Please try refreshing the page or contact support if the problem
persists.
</p>
<button
className='btn btn-primary'
onClick={() => window.location.reload()}
>
Retry
</button>
</div>
);
}
return <>{isResponseSuccess(userResponse) && user && children}</>;
}; };
export default RequireAuth; export default RequireAuth;
@@ -0,0 +1,104 @@
'use client';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
export interface DrawerHeaderProps {
// Left side props
leftIcon?: string;
leftIconSize?: number;
leftIconHref?: string;
leftIconOnClick?: () => void;
leftIconClassName?: string;
// Subtitle/label props
subtitle?: string | ReactNode;
subtitleClassName?: string;
// Right side actions (children)
children?: ReactNode;
// Container props
className?: string;
showDivider?: boolean;
}
const DrawerHeader = ({
leftIcon = 'mdi:close',
leftIconSize = 24,
leftIconHref,
leftIconOnClick,
leftIconClassName,
subtitle,
subtitleClassName,
children,
className,
showDivider = true,
}: DrawerHeaderProps) => {
const renderLeftIcon = () => {
const iconElement = (
<Icon
icon={leftIcon}
width={leftIconSize}
height={leftIconSize}
className={cn('cursor-pointer', leftIconClassName)}
/>
);
if (leftIconHref) {
return (
<Link href={leftIconHref} className='hover:text-gray-400'>
{iconElement}
</Link>
);
}
if (leftIconOnClick) {
return (
<button
onClick={leftIconOnClick}
className='hover:text-gray-400 bg-transparent border-none p-0'
>
{iconElement}
</button>
);
}
return iconElement;
};
return (
<div
className={cn(
'flex flex-row justify-between items-center px-4 pt-4',
className
)}
>
{/* Left Side */}
<div className='flex flex-row h-full gap-2 items-center'>
{renderLeftIcon()}
{showDivider && subtitle && (
<div className='divider divider-horizontal p-0 m-0'></div>
)}
{subtitle && (
<div className={cn('text-sm text-neutral', subtitleClassName)}>
{subtitle}
</div>
)}
</div>
{/* Right Side Actions */}
{children && (
<div className='flex flex-row gap-3 justify-end items-center'>
{children}
</div>
)}
</div>
);
};
export default DrawerHeader;
+13 -2
View File
@@ -2,8 +2,9 @@
import { HTMLProps, useEffect, useRef } from 'react'; import { HTMLProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> { interface CheckboxInputProps extends Omit<HTMLProps<HTMLInputElement>, 'size'> {
name: string; name: string;
label?: string; label?: string;
indeterminate?: boolean; indeterminate?: boolean;
@@ -16,6 +17,7 @@ interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
isError?: boolean; isError?: boolean;
isValid?: boolean; isValid?: boolean;
errorMessage?: string; errorMessage?: string;
size?: Size;
} }
const CheckboxInput = ({ const CheckboxInput = ({
@@ -27,10 +29,19 @@ const CheckboxInput = ({
isValid, isValid,
isError, isError,
errorMessage, errorMessage,
size = 'sm',
...rest ...rest
}: CheckboxInputProps) => { }: CheckboxInputProps) => {
const ref = useRef<HTMLInputElement>(null!); const ref = useRef<HTMLInputElement>(null!);
const checkboxBaseClassName = cn('checkbox cursor-pointer rounded-md', {
'checkbox-xs': size === 'xs',
'checkbox-sm': size === 'sm',
'checkbox-md': size === 'md',
'checkbox-lg': size === 'lg',
'checkbox-xl': size === 'xl',
});
useEffect(() => { useEffect(() => {
if (typeof indeterminate === 'boolean') { if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !rest.checked && indeterminate; ref.current.indeterminate = !rest.checked && indeterminate;
@@ -53,7 +64,7 @@ const CheckboxInput = ({
id={name} id={name}
name={name} name={name}
className={cn( className={cn(
'checkbox cursor-pointer', checkboxBaseClassName,
{ {
'border-error': isError, 'border-error': isError,
'border-success': isValid, 'border-success': isValid,
+2 -2
View File
@@ -7,11 +7,11 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '../Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker'; import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css'; import 'react-day-picker/dist/style.css';
import Button from '../Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
export interface DateInputProps { export interface DateInputProps {
label?: string; label?: string;
+174 -75
View File
@@ -1,6 +1,11 @@
'use client'; 'use client';
import { ChangeEventHandler, ReactNode } from 'react'; import {
ChangeEventHandler,
ReactNode,
createContext,
useContext,
} from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
export interface RadioOption { export interface RadioOption {
@@ -8,37 +13,74 @@ export interface RadioOption {
value: string; value: string;
} }
export interface RadioInputProps { // DaisyUI Radio Colors
label?: string; export type RadioColor =
bottomLabel?: string; | 'neutral'
| 'primary'
| 'secondary'
| 'accent'
| 'success'
| 'warning'
| 'info'
| 'error';
// DaisyUI Radio Sizes
export type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
// Context untuk RadioGroup
interface RadioGroupContextValue {
name: string; name: string;
value?: string; value?: string;
options: RadioOption[]; color?: RadioColor;
variant?: string; size?: RadioSize;
className?: {
wrapper?: string;
label?: string;
radioWrapper?: string;
radio?: string;
};
isError?: boolean;
isValid?: boolean;
errorMessage?: string;
required?: boolean;
disabled?: boolean; disabled?: boolean;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void; onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
} }
const RadioInput = ({ const RadioGroupContext = createContext<RadioGroupContextValue | undefined>(
undefined
);
const useRadioGroup = () => {
const context = useContext(RadioGroupContext);
if (!context) {
throw new Error('RadioGroupItem must be used within RadioGroup');
}
return context;
};
// RadioGroup Component
export interface RadioGroupProps {
label?: string;
bottomLabel?: string;
name: string;
value?: string;
options?: RadioOption[];
color?: RadioColor;
size?: RadioSize;
className?: {
wrapper?: string;
label?: string;
radioWrapper?: string;
};
isError?: boolean;
errorMessage?: string;
required?: boolean;
disabled?: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
children?: ReactNode;
}
export const RadioGroup = ({
label, label,
bottomLabel, bottomLabel,
name, name,
value, value,
options, options,
variant = 'radio-primary', color = 'primary',
size = 'md',
className, className,
isError, isError,
errorMessage, errorMessage,
@@ -46,68 +88,125 @@ const RadioInput = ({
disabled = false, disabled = false,
onChange, onChange,
onBlur, onBlur,
}: RadioInputProps) => { children,
return ( }: RadioGroupProps) => {
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}> const contextValue: RadioGroupContextValue = {
{/* Label atas */} name,
{label && ( value,
<label color,
className={cn( size,
'w-full text-sm font-normal leading-5', disabled,
{ 'text-error': isError }, onChange,
className?.label onBlur,
)} };
>
{label}
{required && (
<span className='text-error ml-1' title='required'>
*
</span>
)}
</label>
)}
{/* Daftar opsi radio */} return (
<div <RadioGroupContext.Provider value={contextValue}>
className={cn( <div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
'flex flex-row flex-wrap gap-4 items-center', {/* Label atas */}
className?.radioWrapper {label && (
)}
>
{options.map((option) => (
<label <label
key={option.value}
className={cn( className={cn(
'flex flex-row items-center gap-2 cursor-pointer', 'w-full text-sm font-normal leading-5',
disabled && 'opacity-60 cursor-not-allowed' { 'text-error': isError },
className?.label
)} )}
> >
<input {label}
type='radio' {required && (
name={name} <span className='text-error ml-1' title='required'>
value={option.value} *
checked={value === option.value} </span>
onChange={onChange} )}
onBlur={onBlur}
disabled={disabled}
className={cn('radio', variant, className?.radio)}
/>
<span className='text-sm'>{option.label}</span>
</label> </label>
))} )}
{/* Daftar opsi radio */}
<div
className={cn(
'flex flex-row flex-wrap gap-4 items-center',
className?.radioWrapper
)}
>
{/* Jika options diberikan, render otomatis */}
{options &&
options.map((option) => (
<RadioGroupItem
key={option.value}
value={option.value}
label={option.label}
/>
))}
{/* Atau gunakan children untuk custom rendering */}
{children}
</div>
{/* Label bawah */}
{!isError && bottomLabel && (
<p className='text-sm opacity-60'>{bottomLabel}</p>
)}
{/* Pesan error */}
{isError && errorMessage && (
<p className='text-sm text-error'>{errorMessage}</p>
)}
</div> </div>
</RadioGroupContext.Provider>
{/* Label bawah */}
{!isError && bottomLabel && (
<p className='text-sm opacity-60'>{bottomLabel}</p>
)}
{/* Pesan error */}
{isError && errorMessage && (
<p className='text-sm text-error'>{errorMessage}</p>
)}
</div>
); );
}; };
export default RadioInput; // RadioGroupItem Component
export interface RadioGroupItemProps {
value: string;
label?: string;
className?: string;
disabled?: boolean;
color?: RadioColor;
size?: RadioSize;
}
export const RadioGroupItem = ({
value,
label,
className,
disabled: itemDisabled,
color: itemColor,
size: itemSize,
}: RadioGroupItemProps) => {
const {
name,
value: groupValue,
color: groupColor,
size: groupSize,
disabled: groupDisabled,
onChange,
onBlur,
} = useRadioGroup();
const isDisabled = itemDisabled ?? groupDisabled;
const radioColor = itemColor ?? groupColor;
const radioSize = itemSize ?? groupSize;
return (
<label
className={cn(
'flex flex-row items-center gap-2 cursor-pointer',
isDisabled && 'opacity-60 cursor-not-allowed',
className
)}
>
<input
type='radio'
name={name}
value={value}
checked={groupValue === value}
onChange={onChange}
onBlur={onBlur}
disabled={isDisabled}
className={cn('radio', `radio-${radioColor}`, `radio-${radioSize}`)}
/>
{label && <span className='text-sm'>{label}</span>}
</label>
);
};
+20 -4
View File
@@ -1,16 +1,32 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface MenuProps { interface MenuProps {
children?: ReactNode; children?: ReactNode;
size?: Size;
direction?: 'vertical' | 'horizontal';
className?: string; className?: string;
} }
const Menu = ({ children, className }: MenuProps) => { const Menu = ({
return ( children,
<ul className={cn('menu w-full p-0 gap-0.5', className)}>{children}</ul> size = 'md',
); direction = 'vertical',
className,
}: MenuProps) => {
const menuBaseClassName = cn('menu w-full', {
'menu-xs': size === 'xs',
'menu-sm': size === 'sm',
'menu-md': size === 'md',
'menu-lg': size === 'lg',
'menu-xl': size === 'xl',
'menu-vertical': direction === 'vertical',
'menu-horizontal': direction === 'horizontal',
});
return <ul className={cn(menuBaseClassName, className)}>{children}</ul>;
}; };
export default Menu; export default Menu;
+92
View File
@@ -0,0 +1,92 @@
import Link from 'next/link';
import Menu from '@/components/menu/Menu';
import { Icon } from '@iconify/react';
import { cn, isPathActive } from '@/lib/helper';
export interface SidebarMenuItem {
type?: 'item' | 'title';
text: string;
link: string;
icon?: string;
submenu?: SidebarMenuItem[];
}
interface SidebarMenuItemProps {
item: SidebarMenuItem;
activeLink: string;
}
interface SidebarMenuProps {
menu: SidebarMenuItem[];
activeLink: string;
}
const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
const isItemActive = isPathActive(activeLink, item.link);
const menuItemWithoutSubmenu = (
<li>
<Link
href={item.link}
className={cn(
{
'menu-active border-2 border-solid border-base-300': isItemActive,
},
'px-3 py-1.5'
)}
>
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span>
</Link>
</li>
);
if (!item.submenu || item.submenu.length === 0) {
return menuItemWithoutSubmenu;
}
const menuItemWithSubmenu = (
<li>
<details open={isItemActive}>
<summary
className={cn({
'text-primary': isItemActive,
})}
>
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span>
</summary>
<ul>
{item.submenu.map((submenuItem, submenuIdx) => (
<SidebarMenuItem
key={`submenu#${submenuIdx}`}
item={submenuItem}
activeLink={activeLink}
/>
))}
</ul>
</details>
</li>
);
return menuItemWithSubmenu;
};
const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => {
return (
<Menu>
{menu.map((menuItem, menuIdx) => (
<SidebarMenuItem
key={menuIdx}
item={menuItem}
activeLink={activeLink}
/>
))}
</Menu>
);
};
export default SidebarMenu;
+51 -15
View File
@@ -144,33 +144,45 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => {
export const formatGroupedApprovalsToApprovalSteps = ( export const formatGroupedApprovalsToApprovalSteps = (
approvalLine: ApprovalLine, approvalLine: ApprovalLine,
groupedApprovals: BaseGroupedApproval[], groupedApprovals: BaseGroupedApproval[] | undefined,
latestApproval: BaseApproval latestApproval: BaseApproval | undefined
): ApprovalStepsProps['approvals'] => { ): ApprovalStepsProps['approvals'] => {
const formattedApprovalSteps: ApprovalStepsProps['approvals'] = const formattedApprovalSteps: ApprovalStepsProps['approvals'] =
approvalLine.map((approvalLineItem) => { approvalLine.map((approvalLineItem) => {
const approvalGroup = groupedApprovals.find( const approvalGroup = groupedApprovals?.find(
(approvalGroupItem) => (approvalGroupItem) =>
approvalGroupItem.step_number === approvalLineItem.step_number approvalGroupItem.step_number === approvalLineItem.step_number
); );
const currentStepNumber = approvalLineItem.step_number; const currentStepNumber = approvalLineItem.step_number;
const lastStepNumber = const lastStepNumber =
groupedApprovals[groupedApprovals.length - 1]?.step_number; groupedApprovals?.[groupedApprovals.length - 1]?.step_number;
const isLatestApprovalRejected = latestApproval.action === 'REJECTED'; const isLatestApprovalRejected = latestApproval?.action === 'REJECTED';
if (!approvalGroup && currentStepNumber <= lastStepNumber) { // Only throw error if we have a valid lastStepNumber to compare against
throw new Error( if (
`Approval dengan ${approvalLineItem.step_name} tidak ditemukan!` !approvalGroup &&
); lastStepNumber !== undefined &&
currentStepNumber <= lastStepNumber
) {
// throw new Error(
// `Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
// );
} }
if (!approvalGroup) { if (!approvalGroup) {
const isWaiting = currentStepNumber === latestApproval.step_number + 1; // Check if this step is waiting (only if we have latestApproval)
const isWaiting =
latestApproval?.step_number !== undefined &&
currentStepNumber === latestApproval.step_number + 1;
// Check if previous approval was rejected
const isPreviousApprovalRejected = const isPreviousApprovalRejected =
groupedApprovals[groupedApprovals.length - 1].approvals[0].action === groupedApprovals &&
'REJECTED'; groupedApprovals.length > 0 &&
groupedApprovals[groupedApprovals.length - 1]?.approvals?.[0]
?.action === 'REJECTED';
return { return {
name: approvalLineItem.step_name, name: approvalLineItem.step_name,
@@ -184,7 +196,11 @@ export const formatGroupedApprovalsToApprovalSteps = (
let approvalStatus: ApprovalStepStatus = 'IDLE'; let approvalStatus: ApprovalStepStatus = 'IDLE';
if (approvalGroup.step_number <= latestApproval.step_number) { // Only compare if latestApproval and its step_number exist
if (
latestApproval?.step_number !== undefined &&
approvalGroup.step_number <= latestApproval.step_number
) {
if (approvalGroup.approvals) { if (approvalGroup.approvals) {
switch (approvalGroup?.approvals[0]?.action) { switch (approvalGroup?.approvals[0]?.action) {
case 'CREATED': case 'CREATED':
@@ -203,6 +219,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
} }
} }
} else if ( } else if (
latestApproval?.step_number !== undefined &&
approvalGroup.step_number === latestApproval.step_number + 1 && approvalGroup.step_number === latestApproval.step_number + 1 &&
!isLatestApprovalRejected !isLatestApprovalRejected
) { ) {
@@ -353,14 +370,33 @@ const useApprovalSteps = ({
// Formatting Akhir // Formatting Akhir
const approvals = useMemo(() => { const approvals = useMemo(() => {
if (isLoading || !approvalLines.length || !latestApproval) { if (isLoading || !approvalLines.length) {
return []; return [];
} }
// Try to derive latestApproval from groupedApprovals if not provided
let effectiveLatestApproval = latestApproval;
if (!effectiveLatestApproval && groupedApprovals.length > 0) {
// Get all approvals from grouped data
const allApprovals = groupedApprovals.flatMap((group) => group.approvals);
if (allApprovals.length > 0) {
// Use the most recent approval (last in array)
effectiveLatestApproval = allApprovals[allApprovals.length - 1];
}
}
// If still no latestApproval, return empty
if (!effectiveLatestApproval) {
return [];
}
try { try {
return formatGroupedApprovalsToApprovalSteps( return formatGroupedApprovalsToApprovalSteps(
approvalLines, approvalLines,
groupedApprovals, groupedApprovals,
latestApproval effectiveLatestApproval
); );
} catch (error) { } catch (error) {
console.warn('Gagal memformat approval steps:', error); console.warn('Gagal memformat approval steps:', error);
@@ -0,0 +1,106 @@
'use client';
import { useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Tabs from '@/components/Tabs';
import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable';
import {
ClosingGeneralInformation,
BaseClosingSales,
} from '@/types/api/closing';
import ClosingSapronakTabContent from './ClosingSapronakTabContent';
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
import SalesReportTable from './sale/SalesReportTable';
interface ClosingDetailProps {
id: number;
initialValue?: ClosingGeneralInformation;
salesData?: BaseClosingSales;
}
const ClosingDetail: React.FC<ClosingDetailProps> = ({
id,
initialValue,
salesData,
}) => {
const [activeTab, setActiveTab] = useState<string>('sapronak');
const closingDetailTabs = useMemo(() => {
const validTabs = [
{
id: 'sapronak',
label: 'Sapronak',
content: <ClosingSapronakTabContent projectFlockId={id} />,
},
{
id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak',
content: <ClosingSapronakCalculationTabContent projectFlockId={id} />,
},
{
id: 'penjualan',
label: 'Penjualan',
content: <SalesReportTable initialValues={salesData} />,
},
{
id: 'overhead',
label: 'Overhead',
content: <ClosingOverheadTabContent projectFlockId={id} />,
},
{
id: 'hppEkspedisi',
label: 'HPP Ekspedisi',
content: 'HPP Ekspedisi',
},
{
id: 'dataProduksi',
label: 'Data Produksi',
content: 'Data Produksi',
},
{
id: 'keuangan',
label: 'Keuangan',
content: 'Keuangan',
},
];
return validTabs;
}, [initialValue]);
return (
<>
<section className='w-full max-w-7xl pb-16'>
<header className='flex flex-col gap-4'>
<Button
href='/closing'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>Detail Closing</h1>
</header>
<ClosingGeneralInformationTable initialValue={initialValue} />
<Tabs
activeTabId={activeTab}
onTabChange={setActiveTab}
tabs={closingDetailTabs}
variant='lifted'
className={{
wrapper: 'w-full mt-4',
}}
/>
</section>
</>
);
};
export default ClosingDetail;
@@ -0,0 +1,100 @@
import { ClosingGeneralInformation } from '@/types/api/closing';
interface ClosingGeneralInformationProps {
initialValue?: ClosingGeneralInformation;
}
const ClosingGeneralInformationTable = ({
initialValue,
}: ClosingGeneralInformationProps) => {
return (
<div className='w-full my-4 @container'>
<div className='flex flex-col @sm:flex-row gap-4'>
<div className='w-full'>
<div className='overflow-x-auto'>
<table className='table table-zebra table-sm'>
<tbody>
<tr>
<td>Lokasi</td>
<td>:</td>
<td>{initialValue?.location_name}</td>
</tr>
<tr>
<td>Periode</td>
<td>:</td>
<td>{initialValue?.period}</td>
</tr>
<tr>
<td>Kategori</td>
<td>:</td>
<td>{initialValue?.project_category}</td>
</tr>
<tr>
<td>Populasi</td>
<td>:</td>
<td>{initialValue?.population} Ekor</td>
</tr>
<tr>
<td>Jenis Project</td>
<td>:</td>
<td>{initialValue?.project_type}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Kandang Aktif</td>
<td>:</td>
<td>{initialValue?.active_house_count} Kandang</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Pembayaran Penjualan</td>
<td>:</td>
<td>{initialValue?.sales_payment_status}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Project</td>
<td>:</td>
<td>{initialValue?.project_status}</td>
</tr>
<tr className='table-row @sm:hidden'>
<td>Status Closing</td>
<td>:</td>
<td>{initialValue?.closing_status}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className='w-full hidden @sm:block'>
<div className='overflow-x-auto'>
<table className='table table-zebra table-sm'>
<tbody>
<tr>
<td>Kandang Aktif</td>
<td>:</td>
<td>{initialValue?.active_house_count} Kandang</td>
</tr>
<tr>
<td>Status Pembayaran Penjualan</td>
<td>:</td>
<td>{initialValue?.sales_payment_status}</td>
</tr>
<tr>
<td>Status Project</td>
<td>:</td>
<td>{initialValue?.project_status}</td>
</tr>
<tr>
<td>Status Closing</td>
<td>:</td>
<td>{initialValue?.closing_status}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default ClosingGeneralInformationTable;
@@ -0,0 +1,209 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronak } from '@/types/api/closing';
interface ClosingIncomingSapronaksTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksTable = ({
projectFlockId,
}: ClosingIncomingSapronaksTableProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`,
ClosingApi.getAllIncomingSapronakFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
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(incomingSapronaks)
? incomingSapronaks.data.length > 0
: false
);
}
}, [incomingSapronaks, 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'>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'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Sapronak Masuk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingIncomingSapronak>
data={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronaks)
? incomingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronaks) &&
incomingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksTable;
@@ -0,0 +1,209 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronak } from '@/types/api/closing';
interface ClosingOutgoingSapronaksTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`,
ClosingApi.getAllOutgoingSapronakFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'date',
header: 'Tanggal',
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
},
{
accessorKey: 'reference_number',
header: 'No. Referensi',
},
{
accessorKey: 'transaction_type',
header: 'Jenis Transaksi',
},
{
accessorKey: 'product_name',
header: 'Produk',
},
{
accessorKey: 'product_category',
header: 'Kategori Produk',
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
header: 'Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
},
{
accessorKey: 'notes',
header: 'Keterangan',
},
];
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(outgoingSapronaks)
? outgoingSapronaks.data.length > 0
: false
);
}
}, [outgoingSapronaks, 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'>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'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Sapronak Keluar'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
</div>
<Table<ClosingOutgoingSapronak>
data={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronaks)
? outgoingSapronaks?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronaks}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronaks) &&
outgoingSapronaks?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksTable;
@@ -0,0 +1,19 @@
import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable';
interface ClosingOverheadTabContentProps {
projectFlockId: number;
}
const ClosingOverheadTabContent = ({
projectFlockId,
}: ClosingOverheadTabContentProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<ClosingOverheadTable projectFlockId={projectFlockId} />
)}
</div>
);
};
export default ClosingOverheadTabContent;
@@ -0,0 +1,162 @@
import Card from '@/components/Card';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing';
import { Overhead, OverheadTotal } from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table';
import { useMemo } from 'react';
import useSWR from 'swr';
interface ClosingOverheadTableProps {
type?: 'detail';
projectFlockId: number;
}
const ClosingOverheadTable = ({
type,
projectFlockId,
}: ClosingOverheadTableProps) => {
const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/overhead`,
() => ClosingApi.getOverhead(projectFlockId),
{
keepPreviousData: true,
}
);
// Helper function to create columns with footer support
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [
// Group untuk kolom tanpa footer
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
{
header: 'Budget Pengajuan',
footer: '',
columns: [
{
id: 'budget_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
footer: total ? () => formatNumber(total.budget_quantity) : '',
},
{
id: 'budget_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.budget_unit_price
? formatCurrency(props.budget_unit_price)
: '-',
footer: '',
},
{
id: 'budget_total_amount',
header: 'Total',
accessorFn: (props) =>
props.budget_total_amount
? formatCurrency(props.budget_total_amount)
: '-',
footer: total ? () => formatCurrency(total.budget_total_amount) : '',
},
],
},
{
header: 'Realisasi',
footer: '',
columns: [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) =>
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) : '',
},
],
},
{
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) : '',
},
];
const columns = useMemo(
() =>
isResponseSuccess(overhead)
? createColumns(overhead.data?.total)
: createColumns(),
[overhead]
);
return (
<>
<Card
title='Pengeluaran Overhead'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<Overhead>
data={
isResponseSuccess(overhead) ? (overhead.data?.overheads ?? []) : []
}
columns={columns}
className={{
containerClassName: 'my-4',
headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'whitespace-nowrap'
),
}}
renderFooter={
isResponseSuccess(overhead)
? overhead.data?.overheads.length > 0
: false
}
/>
</Card>
</>
);
};
export default ClosingOverheadTable;
@@ -0,0 +1,25 @@
'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
interface ClosingSapronakCalculationTabContentProps {
projectFlockId?: number;
}
const ClosingSapronakCalculationTabContent = ({
projectFlockId,
}: ClosingSapronakCalculationTabContentProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<>
<ClosingSapronakCalculationTable projectFlockId={projectFlockId} />
</>
)}
</div>
);
};
export default ClosingSapronakCalculationTabContent;
@@ -0,0 +1,221 @@
'use client';
import Card from '@/components/Card';
import Table from '@/components/Table';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import {
RowSapronakCalculation,
TotalSapronakCalculation,
} from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table';
import { useMemo } from 'react';
import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper';
interface ClosingSapronakCalculationTableProps {
type?: 'detail';
projectFlockId: number;
}
const ClosingSapronakCalculationTable = ({
type,
projectFlockId,
}: ClosingSapronakCalculationTableProps) => {
const { data: sapronakCalculation, isLoading } = useSWR(
`/closing/sapronak-calculation/${projectFlockId}`,
() => ClosingApi.getPerhitunganSapronak(projectFlockId),
{
keepPreviousData: true,
}
);
// Helper function to create columns with footer support
const createColumns = (
total?: TotalSapronakCalculation
): ColumnDef<RowSapronakCalculation>[] => [
{
header: 'Tanggal',
accessorKey: 'tanggal',
cell: (props) => (props.getValue() as string) || '-',
footer: 'Total',
},
{
header: 'No. Referensi',
accessorKey: 'no_referensi',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'QTY Masuk',
accessorKey: 'qty_masuk',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_masuk)}
</div>
)
: '',
},
{
header: 'QTY Keluar',
accessorKey: 'qty_keluar',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_keluar)}
</div>
)
: '',
},
{
header: 'QTY Pakai',
accessorKey: 'qty_pakai',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_pakai)}
</div>
)
: '',
},
{
header: 'Uraian',
accessorKey: 'uraian',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'Kategori Produk',
accessorKey: 'kategori_produk',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'Harga Beli/Qty (Rp)',
accessorKey: 'harga_beli_per_qty',
cell: (props) => formatCurrency(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.harga_beli_per_qty)}
</div>
)
: '',
},
{
header: 'Total Harga (Rp)',
accessorKey: 'total_harga',
cell: (props) => formatCurrency(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.total_harga)}
</div>
)
: '',
},
{
header: 'Keterangan',
accessorKey: 'keterangan',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
];
// Memoize columns untuk setiap kategori
const docBroilerColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.doc_broiler.total)
: createColumns(),
[sapronakCalculation]
);
const ovkColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.ovk.total)
: createColumns(),
[sapronakCalculation]
);
const pakanColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.pakan.total)
: createColumns(),
[sapronakCalculation]
);
return (
<div className='flex flex-col gap-4'>
{isResponseSuccess(sapronakCalculation) && (
<>
<Card
title='DOC Broiler'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.doc_broiler.rows ?? []}
columns={docBroilerColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='OVK'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.ovk.rows ?? []}
columns={ovkColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='Pakan'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.pakan.rows ?? []}
columns={pakanColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
</>
)}
</div>
);
};
export default ClosingSapronakCalculationTable;
@@ -0,0 +1,26 @@
'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
interface ClosingSapronakTableProps {
projectFlockId?: number;
}
const ClosingSapronakTabContent = ({
projectFlockId,
}: ClosingSapronakTableProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<>
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
</>
)}
</div>
);
};
export default ClosingSapronakTabContent;
@@ -0,0 +1,299 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { LocationApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { ClosingApi } from '@/services/api/closing';
import { Closing } from '@/types/api/closing';
const PROJECT_STATUS_OPTIONS = [
{
value: 1,
label: 'Pengajuan',
},
{
value: 2,
label: 'Aktif',
},
];
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Closing, unknown>;
}) => {
return (
<RowOptionsMenuWrapper type={type}>
{/* TODO: apply RBAC */}
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<Button
href={`/closing/detail/?closingId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</div>
</RowOptionsMenuWrapper>
);
};
const ClosingsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
locationId: '',
projectStatus: '',
userId: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
transactionDate: 'transaction_date',
realizationDate: 'realization_date',
locationId: 'location_id',
projectStatus: 'project_status',
userId: 'user_id',
},
});
const { data: closings, isLoading: isLoadingClosings } = useSWR(
`${ClosingApi.basePath}${getTableFilterQueryString()}`,
ClosingApi.getAllFetcher
);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const closingsColumns: ColumnDef<Closing>[] = [
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'location_name',
header: 'Lokasi',
},
{
accessorKey: 'project_category',
header: 'Kategori',
},
{
accessorKey: 'period',
header: 'Periode',
},
{
accessorKey: 'closing_date',
header: 'Periode',
cell: (props) =>
formatDate(props.row.original.closing_date, 'DD MMM YYYY'),
},
{
accessorKey: 'shed_label',
header: 'Jumlah Kandang',
},
{
accessorKey: 'sales_paid_amount',
header: 'Jumlah Sudah Bayar',
cell: (props) => (
<span className='text-success'>
{formatCurrency(props.row.original.sales_paid_amount)}
</span>
),
},
{
accessorKey: 'sales_remaining_amount',
header: 'Jumlah Sisa Bayar',
cell: (props) => (
<span className='text-error'>
{formatCurrency(props.row.original.sales_remaining_amount)}
</span>
),
},
{
accessorKey: 'sales_payment_status',
header: 'Status Pembayaran',
},
{
accessorKey: 'project_status',
header: 'Status',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
return (
<>
{currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 3 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
},
},
];
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
);
};
const [selectedProjectStatus, setSelectedProjectStatus] =
useState<OptionType | null>(null);
const projectStatusChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedProjectStatus(val as OptionType);
updateFilter(
'projectStatus',
val ? ((val as OptionType).value as string) : ''
);
};
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]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-end items-end sm:items-center gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Closing'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-2'>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
<SelectInput
label='Status Project'
placeholder='Pilih Status'
options={PROJECT_STATUS_OPTIONS}
value={selectedProjectStatus}
onChange={projectStatusChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
</div>
</div>
</div>
<Table<Closing>
data={isResponseSuccess(closings) ? closings?.data : []}
columns={closingsColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={isResponseSuccess(closings) ? closings?.meta?.page : 0}
totalItems={
isResponseSuccess(closings) ? closings?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoadingClosings}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(closings) && closings?.data?.length === 0,
}),
}}
/>
</div>
</>
);
};
export default ClosingsTable;
@@ -0,0 +1,285 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import { BaseClosingSales, BaseSales } from '@/types/api/closing';
import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer';
import { Kandang } from '@/types/api/master-data/kandang';
interface SalesReportTableProps {
type?: 'detail';
initialValues?: BaseClosingSales;
}
const SalesReportTable = ({
type = 'detail',
initialValues,
}: SalesReportTableProps) => {
const salesData: BaseSales[] = useMemo(() => {
return initialValues?.sales || [];
}, [initialValues]);
const totals = useMemo(() => {
if (salesData.length === 0) {
return {
totalQuantity: 0,
totalWeight: 0,
avgWeight: 0,
avgPricePartner: 0,
totalPartner: 0,
};
}
const totalQuantity = salesData.reduce(
(sum, item) => sum + (item.qty || 0),
0
);
const totalWeight = salesData.reduce(
(sum, item) => sum + (item.weight || 0),
0
);
const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0;
const validPriceItems = salesData.filter(
(item) => item.price != null && item.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
);
return {
totalQuantity,
totalWeight,
avgWeight,
avgPricePartner,
totalPartner,
};
}, [salesData]);
const salesColumns: ColumnDef<BaseSales>[] = useMemo(
() => [
{
id: 'realization_date',
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
cell: (props) => {
const date = props.row.original.realization_date;
return date ? formatDate(date, 'DD MMM YYYY') : '-';
},
footer: () => (
<div className='font-semibold text-gray-900'>Total Penjualan</div>
),
},
{
id: 'age',
accessorKey: 'age',
header: 'Umur',
cell: (props) => props.getValue() || '-',
},
{
id: 'do_number',
accessorKey: 'do_number',
header: 'No. DO',
cell: (props) => props.getValue() || '-',
},
{
id: 'product',
accessorKey: 'product',
header: 'Produk',
cell: (props) => {
const product = props.getValue() as Product;
return product?.name || '-';
},
},
{
id: 'customer',
accessorKey: 'customer',
header: 'Customer',
cell: (props) => {
const customer = props.getValue() as Customer;
return customer?.name || '-';
},
},
{
id: 'jumlah',
header: 'Jumlah',
columns: [
{
id: 'qty',
accessorKey: 'qty',
header: 'Kuantitas',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.totalQuantity)}
</div>
),
},
{
id: 'weight',
accessorKey: 'weight',
header: 'Kg',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.totalWeight)}
</div>
),
},
],
},
{
id: 'avg_weight',
accessorKey: 'avg_weight',
header: 'AVG (Kg)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.avgWeight)}
</div>
),
},
{
id: 'price_partner',
accessorKey: 'price',
header: 'Harga Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.avgPricePartner)}
</div>
),
},
{
id: 'total_mitra',
accessorKey: 'total_price',
header: 'Total Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalPartner)}
</div>
),
},
{
id: 'price_act',
accessorKey: 'price',
header: 'Harga Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'total_act',
accessorKey: 'total_price',
header: 'Total Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'kandang',
accessorKey: 'kandang',
header: 'Kandang',
cell: (props) => {
const kandang = props.getValue() as Kandang;
return kandang?.name || '-';
},
},
{
id: 'payment_status',
accessorKey: 'payment_status',
header: 'Status Pembayaran',
cell: (props) => {
const status = props.getValue() as string;
const getStatusColor = (status: string) => {
if (!status) return 'neutral';
switch (status.toLowerCase()) {
case 'paid':
return 'success';
case 'tempo':
return 'warning';
default:
return 'neutral';
}
};
return (
<Badge variant='soft' size='sm' color={getStatusColor(status)}>
{status || '-'}
</Badge>
);
},
},
],
[]
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>Penjualan</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
body: 'p-0',
}}
>
<Table
data={salesData}
columns={salesColumns}
renderFooter={salesData.length > 0}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</Card>
</div>
</section>
</>
);
};
export default SalesReportTable;
@@ -207,7 +207,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -238,7 +238,7 @@ const ExpenseRealizationContent = ({
<tr key={pengajuanIdx}> <tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td> <td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td> <td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.total_price)}</td> <td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td> <td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
</tr> </tr>
) )
@@ -269,7 +269,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.total_price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -300,7 +300,7 @@ const ExpenseRealizationContent = ({
<tr key={realisasiIdx}> <tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td> <td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td> <td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.total_price)}</td> <td>{formatCurrency(realisasiItem.price)}</td>
<td className='w-xs'>{realisasiItem.note ?? '-'}</td> <td className='w-xs'>{realisasiItem.note ?? '-'}</td>
</tr> </tr>
) )
@@ -402,7 +402,10 @@ const ExpenseRequestContent = ({
<th>Tanggal Transaksi</th> <th>Tanggal Transaksi</th>
<th>:</th> <th>:</th>
<td> <td>
{formatDate(initialValues?.expense_date, 'DD MMMM YYYY')} {formatDate(
initialValues?.transaction_date,
'DD MMMM YYYY'
)}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -529,7 +532,7 @@ const ExpenseRequestContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -550,7 +553,7 @@ const ExpenseRequestContent = ({
<tr> <tr>
<th>Nonstock</th> <th>Nonstock</th>
<th>Total Kuantitas</th> <th>Total Kuantitas</th>
<th>Total Biaya</th> <th>Harga Satuan</th>
<th>Catatan</th> <th>Catatan</th>
</tr> </tr>
</thead> </thead>
@@ -560,9 +563,7 @@ const ExpenseRequestContent = ({
<tr key={pengajuanIdx}> <tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td> <td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td> <td>{pengajuanItem.qty}</td>
<td> <td>{formatCurrency(pengajuanItem.price)}</td>
{formatCurrency(pengajuanItem.total_price)}
</td>
<td className='w-xs'> <td className='w-xs'>
{pengajuanItem.note ?? '-'} {pengajuanItem.note ?? '-'}
</td> </td>
@@ -263,11 +263,11 @@ const ExpensesTable = () => {
}, },
}, },
{ {
accessorKey: 'expense_date', accessorKey: 'transaction_date',
header: 'Tanggal Pengajuan', header: 'Tanggal Pengajuan',
cell: (props) => cell: (props) =>
props.row.original.expense_date props.row.original.transaction_date
? formatDate(props.row.original.expense_date, 'DD MMM YYYY') ? formatDate(props.row.original.transaction_date, 'DD MMM YYYY')
: '-', : '-',
}, },
{ {
@@ -27,7 +27,7 @@ type ExpenseRealizationFormSchemaType = {
label: string; label: string;
}; };
quantity?: number; quantity?: number;
total_cost?: number; price?: number;
notes?: string; notes?: string;
}[]; }[];
}[]; }[];
@@ -82,7 +82,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
label: Yup.string().required(), label: Yup.string().required(),
}).required('Nonstock wajib diisi!'), }).required('Nonstock wajib diisi!'),
quantity: Yup.number().required('Total kuantitas wajib diisi!'), quantity: Yup.number().required('Total kuantitas wajib diisi!'),
total_cost: Yup.number().required('Total biaya wajib diisi!'), price: Yup.number().required('Harga satuan wajib diisi!'),
notes: Yup.string(), notes: Yup.string(),
}) })
) )
@@ -155,7 +155,7 @@ export const getExpenseRealizationFormInitialValues = (
label: realisasiItem.nonstock.name, label: realisasiItem.nonstock.name,
}, },
quantity: realisasiItem.qty, quantity: realisasiItem.qty,
total_cost: realisasiItem.total_price, price: realisasiItem.price,
notes: realisasiItem.note, notes: realisasiItem.note,
}; };
}) })
@@ -166,7 +166,7 @@ export const getExpenseRealizationFormInitialValues = (
label: expenseItem.nonstock.name, label: expenseItem.nonstock.name,
}, },
quantity: expenseItem.qty, quantity: expenseItem.qty,
total_cost: expenseItem.total_price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.note,
})) }))
: []; : [];
@@ -98,15 +98,10 @@ const ExpenseRealizationForm = ({
values.realizations.forEach((realization) => { values.realizations.forEach((realization) => {
realization.cost_items.forEach((costItem) => { realization.cost_items.forEach((costItem) => {
const unitPrice =
parseFloat(String(costItem.total_cost)) /
parseFloat(String(costItem.quantity));
const realizationItem = { const realizationItem = {
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,
unit_price: unitPrice, price: parseFloat(String(costItem.price)) as number,
total_price: parseFloat(String(costItem.total_cost)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
}; };
@@ -177,7 +172,7 @@ const ExpenseRealizationForm = ({
{ {
nonstock: undefined, nonstock: undefined,
quantity: undefined, quantity: undefined,
total_cost: undefined, price: undefined,
notes: '', notes: '',
}, },
], ],
@@ -48,7 +48,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
}; };
const isExpenseRepeaterInputError = ( const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes', column: 'nonstock' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
@@ -112,7 +112,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
<tr> <tr>
<th>Nonstock</th> <th>Nonstock</th>
<th>Total Kuantitas</th> <th>Total Kuantitas</th>
<th>Total Biaya</th> <th>Harga Satuan</th>
<th>Catatan</th> <th>Catatan</th>
</tr> </tr>
</thead> </thead>
@@ -163,17 +163,17 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`} name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Total Biaya' placeholder='Masukkan Harga Satuan'
value={ value={
formik.values.realizations[ formik.values.realizations[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].total_cost ?? '' ].cost_items[expenseIdx].price ?? ''
} }
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError( isError={isExpenseRepeaterInputError(
'total_cost', 'price',
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
@@ -20,7 +20,7 @@ type ExpenseFormSchemaType = {
existing_documents?: { id: number; name: string; url: string }[]; existing_documents?: { id: number; name: string; url: string }[];
deleted_documents?: number[]; deleted_documents?: number[];
documents?: File[]; documents?: File[];
cost_per_kandangs: { expense_nonstocks: {
kandang_id: number; kandang_id: number;
cost_items: { cost_items: {
nonstock?: { nonstock?: {
@@ -28,7 +28,7 @@ type ExpenseFormSchemaType = {
label: string; label: string;
}; };
quantity?: number; quantity?: number;
total_cost?: number; price?: number;
notes?: string; notes?: string;
}[]; }[];
}[]; }[];
@@ -74,7 +74,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
documents: Yup.array().of(Yup.mixed<File>().required()).optional(), documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
cost_per_kandangs: Yup.array() expense_nonstocks: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(), kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
@@ -86,7 +86,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
label: Yup.string().required(), label: Yup.string().required(),
}).required('Nonstock wajib diisi!'), }).required('Nonstock wajib diisi!'),
quantity: Yup.number().required('Total kuantitas wajib diisi!'), quantity: Yup.number().required('Total kuantitas wajib diisi!'),
total_cost: Yup.number().required('Total biaya wajib diisi!'), price: Yup.number().required('Harga satuan wajib diisi!'),
notes: Yup.string(), notes: Yup.string(),
}) })
) )
@@ -128,8 +128,8 @@ export const getExpenseFormInitialValues = (
label: initialValues.location.name, label: initialValues.location.name,
} }
: undefined, : undefined,
transaction_date: initialValues?.expense_date transaction_date: initialValues?.transaction_date
? formatDate(initialValues.expense_date, 'YYYY-MM-DD') ? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
: undefined, : undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({ kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id, id: kandang.kandang_id,
@@ -148,7 +148,7 @@ export const getExpenseFormInitialValues = (
})), })),
deleted_documents: [], deleted_documents: [],
documents: [], documents: [],
cost_per_kandangs: initialValues?.kandangs expense_nonstocks: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => ({ ? initialValues.kandangs.map((kandangExpense) => ({
kandang_id: kandangExpense.kandang_id, kandang_id: kandangExpense.kandang_id,
cost_items: kandangExpense.pengajuans cost_items: kandangExpense.pengajuans
@@ -158,7 +158,7 @@ export const getExpenseFormInitialValues = (
label: expenseItem.nonstock.name, label: expenseItem.nonstock.name,
}, },
quantity: expenseItem.qty, quantity: expenseItem.qty,
total_cost: expenseItem.total_price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.note,
})) }))
: [], : [],
@@ -110,12 +110,12 @@ const ExpenseRequestForm = ({
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
cost_per_kandangs: values.cost_per_kandangs.map((costPerKandang) => ({ expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({
kandang_id: costPerKandang.kandang_id, kandang_id: expenseNonstock.kandang_id,
cost_items: costPerKandang.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
total_cost: parseFloat(String(costItem.total_cost)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
})), })),
@@ -132,13 +132,13 @@ const ExpenseRequestForm = ({
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
cost_per_kandang: values.cost_per_kandangs.map( expense_nonstocks: values.expense_nonstocks.map(
(costPerKandang) => ({ (expenseNonstock) => ({
kandang_id: costPerKandang.kandang_id, kandang_id: expenseNonstock.kandang_id,
cost_items: costPerKandang.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
total_cost: parseFloat(String(costItem.total_cost)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
}) })
@@ -179,53 +179,54 @@ const ExpenseRequestForm = ({
formik.setFieldValue('location', val); formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []); formik.setFieldValue('kandangs', []);
formik.setFieldValue('cost_per_kandangs', []); formik.setFieldValue('expense_nonstocks', []);
}; };
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
formik.setFieldTouched('kandangs', true); formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs); formik.setFieldValue('kandangs', kandangs);
const newCostPerKandangs = [...(formik.values.cost_per_kandangs ?? [])]; const newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])];
// add new cost_per_kandangs // add new expense_nonstocks
kandangs.forEach((kandangItem) => { kandangs.forEach((kandangItem) => {
const isKandangExistInCostPerKandangs = newCostPerKandangs.find( const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find(
(costPerKandangItem) => costPerKandangItem.kandang_id === kandangItem.id (expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id
); );
if (isKandangExistInCostPerKandangs) return; if (isKandangExistInExpenseNonstocks) return;
newCostPerKandangs.push({ newExpenseNonstocks.push({
kandang_id: kandangItem.id, kandang_id: kandangItem.id,
cost_items: [ cost_items: [
{ {
nonstock: undefined, nonstock: undefined,
quantity: undefined, quantity: undefined,
total_cost: undefined, price: undefined,
notes: '', notes: '',
}, },
], ],
}); });
}); });
// prune cost_per_kandangs // prune expense_nonstocks
const kandangIds = new Set(kandangs.map((kandang) => kandang.id)); const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedCostPerKandangsIdx: number[] = []; const deletedExpenseNonstocksIdx: number[] = [];
newCostPerKandangs.forEach((costPerKandang, idx) => { newExpenseNonstocks.forEach((expenseNonstock, idx) => {
const isCostPerKandangValid = kandangIds.has(costPerKandang.kandang_id); const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id);
if (!isCostPerKandangValid) { if (!isExpenseNonstockValid) {
deletedCostPerKandangsIdx.push(idx); deletedExpenseNonstocksIdx.push(idx);
} }
}); });
deletedCostPerKandangsIdx.forEach((deletedCostPerKandangIdx) => { deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => {
newCostPerKandangs.splice(deletedCostPerKandangIdx, 1); newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1);
}); });
formik.setFieldValue('cost_per_kandangs', newCostPerKandangs); formik.setFieldValue('expense_nonstocks', newExpenseNonstocks);
}; };
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -41,28 +41,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
) => { ) => {
formik.setFieldTouched( formik.setFieldTouched(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true true
); );
formik.setFieldValue( formik.setFieldValue(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val val
); );
}; };
const addExpenseItemHandler = (kandangExpenseIdx: number) => { const addExpenseItemHandler = (kandangExpenseIdx: number) => {
const newExpensesValue = [ const newExpensesValue = [
...formik.values.cost_per_kandangs[kandangExpenseIdx].cost_items, ...formik.values.expense_nonstocks[kandangExpenseIdx].cost_items,
{ {
nonstock: undefined, nonstock: undefined,
total_cost: undefined, price: undefined,
quantity: undefined, quantity: undefined,
notes: '', notes: '',
}, },
]; ];
formik.setFieldValue( formik.setFieldValue(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items`, `expense_nonstocks[${kandangExpenseIdx}].cost_items`,
newExpensesValue newExpensesValue
); );
}; };
@@ -71,28 +71,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
const path = `cost_per_kandangs[${kandangExpenseIdx}].cost_items`; const path = `expense_nonstocks[${kandangExpenseIdx}].cost_items`;
// trims values, errors, and touched at expenseIdx // trims values, errors, and touched at expenseIdx
removeArrayItemAndSync(formik, path, expenseIdx); removeArrayItemAndSync(formik, path, expenseIdx);
}; };
const isExpenseRepeaterInputError = ( const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes', column: 'nonstock' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
return ( return (
formik.touched.cost_per_kandangs?.[kandangExpenseIdx]?.cost_items?.[ formik.touched.expense_nonstocks?.[kandangExpenseIdx]?.cost_items?.[
expenseIdx expenseIdx
]?.[column] && ]?.[column] &&
Boolean( Boolean(
formik.errors.cost_per_kandangs?.[kandangExpenseIdx] instanceof formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof
Object && Object &&
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
] instanceof Object && ] instanceof Object &&
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
]?.[column] ]?.[column]
) )
@@ -113,7 +113,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div> </div>
<div className='w-full flex flex-col gap-6'> <div className='w-full flex flex-col gap-6'>
{(formik.values.cost_per_kandangs.length === 0 || {(formik.values.expense_nonstocks.length === 0 ||
!formik.values.supplier?.value) && ( !formik.values.supplier?.value) && (
<div> <div>
<p className='text-sm text-gray-400 text-center'> <p className='text-sm text-gray-400 text-center'>
@@ -122,9 +122,9 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div> </div>
)} )}
{formik.values.cost_per_kandangs.length > 0 && {formik.values.expense_nonstocks.length > 0 &&
formik.values.supplier?.value && formik.values.supplier?.value &&
formik.values.cost_per_kandangs.map( formik.values.expense_nonstocks.map(
(kandangExpense, kandangExpenseIdx) => { (kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find( const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id (kandang) => kandang.id === kandangExpense.kandang_id
@@ -147,7 +147,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<tr> <tr>
<th>Nonstock</th> <th>Nonstock</th>
<th>Total Kuantitas</th> <th>Total Kuantitas</th>
<th>Total Biaya</th> <th>Harga Satuan</th>
<th>Catatan</th> <th>Catatan</th>
{type !== 'detail' && <th>Aksi</th>} {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
@@ -178,10 +178,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
required required
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`} name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas' placeholder='Masukkan Total Kuantitas'
value={ value={
formik.values.cost_per_kandangs[ formik.values.expense_nonstocks[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? '' ].cost_items[expenseIdx].quantity ?? ''
} }
@@ -198,18 +198,17 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`} name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Total Biaya' placeholder='Masukkan Harga Satuan'
value={ value={
formik.values.cost_per_kandangs[ formik.values.expense_nonstocks[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].total_cost ?? ].cost_items[expenseIdx].price ?? ''
''
} }
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError( isError={isExpenseRepeaterInputError(
'total_cost', 'price',
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
@@ -224,10 +223,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<TextInput <TextInput
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`} name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan' placeholder='Tuliskan catatan'
value={ value={
formik.values.cost_per_kandangs[ formik.values.expense_nonstocks[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].notes ?? '' ].cost_items[expenseIdx].notes ?? ''
} }
@@ -224,7 +224,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Vendor', value: expense?.supplier.name }, { label: 'Vendor', value: expense?.supplier.name },
{ {
label: 'Tanggal Transaksi', label: 'Tanggal Transaksi',
value: formatDate(expense?.expense_date, 'DD MMMM YYYY'), value: formatDate(expense?.transaction_date, 'DD MMMM YYYY'),
}, },
{ {
label: 'Tanggal Realisasi', label: 'Tanggal Realisasi',
@@ -326,7 +326,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRequestTotal = 0; let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.total_price) (item) => (expenseRequestTotal += item.price)
); );
return ( return (
@@ -374,7 +374,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
<Text <Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText} style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
> >
Total Biaya Harga Satuan
</Text> </Text>
</View> </View>
<View <View
@@ -424,7 +424,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(pengajuan.total_price)} {formatCurrency(pengajuan.price)}
</Text> </Text>
</View> </View>
<View <View
@@ -484,7 +484,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRealizationTotal = 0; let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.total_price) (item) => (expenseRealizationTotal += item.price)
); );
return ( return (
@@ -532,7 +532,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
<Text <Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText} style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
> >
Total Biaya Harga Satuan
</Text> </Text>
</View> </View>
<View <View
@@ -582,7 +582,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(realisasi.total_price)} {formatCurrency(realisasi.price)}
</Text> </Text>
</View> </View>
<View <View
@@ -6,7 +6,7 @@ import Table from '@/components/Table';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { inventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -41,8 +41,8 @@ const InventoryAdjustmentTable = () => {
// Fetch Data // Fetch Data
const { data: inventoryAdjustments, isLoading } = useSWR( const { data: inventoryAdjustments, isLoading } = useSWR(
`${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
inventoryAdjustmentApi.getAllFetcher InventoryAdjustmentApi.getAllFetcher
); );
// State // State
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { inventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
InventoryAdjustment, InventoryAdjustment,
@@ -24,7 +24,7 @@ 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 } from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import RadioInput from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
@@ -52,7 +52,7 @@ const InventoryAdjustmentForm = ({
const createInventoryAdjustmentHandler = useCallback( const createInventoryAdjustmentHandler = useCallback(
async (payload: CreateInventoryAdjustmentPayload) => { async (payload: CreateInventoryAdjustmentPayload) => {
const createInventoryAdjustmentRes = const createInventoryAdjustmentRes =
await inventoryAdjustmentApi.create(payload); await InventoryAdjustmentApi.create(payload);
if (isResponseError(createInventoryAdjustmentRes)) { if (isResponseError(createInventoryAdjustmentRes)) {
setInventoryAdjustmentFormErrorMessage( setInventoryAdjustmentFormErrorMessage(
@@ -347,7 +347,7 @@ const InventoryAdjustmentForm = ({
/> />
{/* Radio Button Flag Stock */} {/* Radio Button Flag Stock */}
<RadioInput <RadioGroup
name='transaction_type' name='transaction_type'
label='Tipe Transaksi' label='Tipe Transaksi'
options={[ options={[
@@ -367,7 +367,7 @@ const InventoryAdjustmentForm = ({
Boolean(formik.errors.transaction_type) Boolean(formik.errors.transaction_type)
} }
errorMessage={formik.errors.transaction_type as string} errorMessage={formik.errors.transaction_type as string}
variant='radio-primary' color='primary'
required required
bottomLabel={ bottomLabel={
formik.values.transaction_type == undefined formik.values.transaction_type == undefined
@@ -0,0 +1,233 @@
'use client';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product';
import { Icon } from '@iconify/react';
import {
CellContext,
ColumnDef,
Row,
SortingState,
} from '@tanstack/react-table';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<InventoryProduct, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RowOptionsMenuWrapper>
);
const InventoryProductTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR(
`${InventoryProductApi.basePath}${getTableFilterQueryString()}`,
InventoryProductApi.getAllFetcher
);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
};
const columns: ColumnDef<InventoryProduct>[] = useMemo(
() => [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'product_price',
header: 'Harga Beli',
cell: (props) => {
return props.row.original.product_price
? formatCurrency(props.row.original.product_price)
: '-';
},
},
{
accessorKey: 'selling_price',
header: 'Harga Jual',
cell: (props) => {
return props.row.original.selling_price
? formatCurrency(props.row.original.selling_price)
: '-';
},
},
{
accessorFn: (row) => row.product_category.name,
header: 'Kategori',
},
{
accessorFn: (row) => row.total_stock,
header: 'Stok',
cell: (props) => {
return props.row.original.total_stock
? formatNumber(props.row.original.total_stock)
: '0';
},
},
{
accessorFn: (row) => row.uom.name,
header: 'Satuan',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
},
},
],
[]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'></div>
</div>
<div className='flex justify-between items-end gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Produk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
</div>
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(inventoryProducts) &&
inventoryProducts?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
</>
);
};
export default InventoryProductTable;
@@ -0,0 +1,118 @@
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react';
const InventoryProductDetail = ({
inventoryProduct,
}: {
inventoryProduct?: InventoryProduct;
}) => {
const stockLogs = useMemo(() => {
return (
inventoryProduct?.product_warehouses?.flatMap(
(warehouse) => warehouse.stock_logs || []
) || []
);
}, [inventoryProduct]);
return (
<div className='flex flex-col gap-4 p-4'>
<FormHeader
title='Detail Persediaan Produk'
backUrl='/inventory/product'
/>
<Card
title='Informasi Produk'
variant='bordered'
className={{
wrapper: 'w-full mt-4',
}}
>
<div className='grid grid-cols-2 gap-4'>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td className='font-semibold'>SKU</td>
<td>:</td>
<td>{inventoryProduct?.sku}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Produk</td>
<td>:</td>
<td>{inventoryProduct?.name}</td>
</tr>
<tr>
<td className='font-semibold'>Kategory</td>
<td>:</td>
<td>{inventoryProduct?.product_category.name}</td>
</tr>
<tr>
<td className='font-semibold'>Satuan</td>
<td>:</td>
<td>{inventoryProduct?.uom.name}</td>
</tr>
</tbody>
</table>
</div>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td className='font-semibold'>Harga Jual</td>
<td>:</td>
<td>
{inventoryProduct?.selling_price
? formatCurrency(inventoryProduct.selling_price)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Harga Beli</td>
<td>:</td>
<td>
{inventoryProduct?.product_price
? formatCurrency(inventoryProduct?.product_price)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Pajak</td>
<td>:</td>
<td>
{inventoryProduct?.tax
? formatCurrency(inventoryProduct?.tax)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Total Stok</td>
<td>:</td>
<td>
{inventoryProduct?.total_stock
? formatNumber(inventoryProduct?.total_stock)
: '0'}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</Card>
<StockProductWarehouseTable
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
/>
<StockLogTable stockLogs={stockLogs} />
</div>
);
};
export default InventoryProductDetail;
@@ -0,0 +1,81 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLog } from '@/types/api/inventory/product';
const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
return (
<Card
title='Informasi Stock Produk'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<StockLog>
data={stockLogs}
columns={[
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
]}
className={{
containerClassName: 'mt-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</Card>
);
};
export default StockLogTable;
@@ -0,0 +1,65 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper';
import {
InventoryProduct,
ProductWarehouseStock,
} from '@/types/api/inventory/product';
const StockProductWarehouseTable = ({
productWarehouseStock,
}: {
productWarehouseStock?: ProductWarehouseStock[];
}) => {
return (
<Card
title='Informasi Gudang'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<ProductWarehouseStock>
data={productWarehouseStock ?? []}
columns={[
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
]}
className={{
containerClassName: 'mt-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</Card>
);
};
export default StockProductWarehouseTable;
@@ -6,7 +6,7 @@ import {
import { import {
DeliveryOrderProductFormValues, DeliveryOrderProductFormValues,
DeliveryOrderProductSchema, DeliveryOrderProductSchema,
} from './repeater/delivery-order/DeliverOrderProduct.schema'; } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
type MarketingSchemaType = { type MarketingSchemaType = {
customer_id: number | undefined; customer_id: number | undefined;
@@ -8,7 +8,6 @@ import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import { formatCurrency, formatDate } from '@/lib/helper'; import { formatCurrency, formatDate } from '@/lib/helper';
import { import {
@@ -31,23 +30,23 @@ import {
DeliveryOrderSchema, DeliveryOrderSchema,
SalesOrderFormValues, SalesOrderFormValues,
SalesOrderSchema, SalesOrderSchema,
} from './MarketingForm.schema'; } from '@/components/pages/marketing/form/MarketingForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { import {
DeliveryOrderApi, DeliveryOrderApi,
MarketingApi, MarketingApi,
SalesOrderApi, SalesOrderApi,
} from '@/services/api/marketing/marketing'; } from '@/services/api/marketing/marketing';
import { SalesOrderProductFormValues } from './repeater/sales-order/SalesOrderProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import SalesOrderProductTable from './table-view/SalesOrderProductTable';
import SalesOrderProductForm from './repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from './table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from './repeater/delivery-order/DeliverOrderProduct';
import { DeliveryOrderProductFormValues } from './repeater/delivery-order/DeliverOrderProduct.schema';
import DebouncedTextArea from '@/components/input/DebouncedTextArea'; import DebouncedTextArea from '@/components/input/DebouncedTextArea';
import SalesOrderProductTable from '@/components/pages/marketing/form/table-view/SalesOrderProductTable';
import SalesOrderProductForm from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from '@/components/pages/marketing/form/table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -156,8 +155,6 @@ export const recalculate = (
field: string, field: string,
values: ProductCalculationFields values: ProductCalculationFields
) => { ) => {
console.log('Values');
console.log(values);
const { qty, unit_price, total_price, avg_weight, total_weight } = values; const { qty, unit_price, total_price, avg_weight, total_weight } = values;
const result: Partial<ProductCalculationFields> = {}; const result: Partial<ProductCalculationFields> = {};
if (field == 'unit_price' || field == 'total_price' || field == 'qty') { if (field == 'unit_price' || field == 'total_price' || field == 'qty') {
@@ -174,8 +171,6 @@ export const recalculate = (
result.avg_weight = Number(total_weight) / Number(qty); result.avg_weight = Number(total_weight) / Number(qty);
} }
} }
console.log('Result');
console.log(result);
return result; return result;
}; };
export const getSubmitField = (values: ProductCalculationFields) => { export const getSubmitField = (values: ProductCalculationFields) => {
@@ -327,8 +322,6 @@ const MarketingForm = ({
}) })
.filter((item) => Boolean(item)), .filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload); } as UpdateDeliveryOrderPayload);
console.log('PAYLOAD');
console.log(payload);
switch (formType) { switch (formType) {
case 'add': case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload); await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -352,7 +345,6 @@ const MarketingForm = ({
// ================== FORM REPEATER HANDLER ================== // ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => { const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(values);
const createMarketingRes = await SalesOrderApi.create(values); const createMarketingRes = await SalesOrderApi.create(values);
if (isResponseSuccess(createMarketingRes)) { if (isResponseSuccess(createMarketingRes)) {
toast.success(createMarketingRes?.message as string); toast.success(createMarketingRes?.message as string);
@@ -365,7 +357,6 @@ const MarketingForm = ({
}; };
const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => { const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(values);
const updateMarketingRes = await SalesOrderApi.update( const updateMarketingRes = await SalesOrderApi.update(
initialValues?.id as number, initialValues?.id as number,
values values
@@ -381,10 +372,8 @@ const MarketingForm = ({
}; };
const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => { const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(initialValues?.id);
const createDeliveryRes = await DeliveryOrderApi.create(values); const createDeliveryRes = await DeliveryOrderApi.create(values);
if (isResponseSuccess(createDeliveryRes)) { if (isResponseSuccess(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.success(createDeliveryRes?.message as string); toast.success(createDeliveryRes?.message as string);
setDeliveryOrderValues( setDeliveryOrderValues(
createDeliveryRes.data?.delivery_order?.flatMap((delivery) => createDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
@@ -397,20 +386,17 @@ const MarketingForm = ({
router.push(`/marketing/detail?marketingId=${initialValues?.id}`); router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
} }
if (isResponseError(createDeliveryRes)) { if (isResponseError(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.error(createDeliveryRes?.message as string); toast.error(createDeliveryRes?.message as string);
} }
setIsLoading(false); setIsLoading(false);
}; };
const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => { const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(initialValues?.id);
const updateDeliveryRes = await DeliveryOrderApi.update( const updateDeliveryRes = await DeliveryOrderApi.update(
initialValues?.id as number, initialValues?.id as number,
values values
); );
if (isResponseSuccess(updateDeliveryRes)) { if (isResponseSuccess(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.success(updateDeliveryRes?.message as string); toast.success(updateDeliveryRes?.message as string);
setDeliveryOrderValues( setDeliveryOrderValues(
mergeSOwithDO( mergeSOwithDO(
@@ -426,7 +412,6 @@ const MarketingForm = ({
router.push(`/marketing/detail?marketingId=${initialValues?.id}`); router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
} }
if (isResponseError(updateDeliveryRes)) { if (isResponseError(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.error(updateDeliveryRes?.message as string); toast.error(updateDeliveryRes?.message as string);
} }
setIsLoading(false); setIsLoading(false);
@@ -435,16 +420,13 @@ const MarketingForm = ({
// ================== MARKETING HANDLER ================== // ================== MARKETING HANDLER ==================
const deleteMarketingHandler = async () => { const deleteMarketingHandler = async () => {
setIsLoading(true); setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete( const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number initialValues?.id as number
); );
if (isResponseSuccess(deleteMarketingRes)) { if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.success(deleteMarketingRes?.message as string); toast.success(deleteMarketingRes?.message as string);
} }
if (isResponseError(deleteMarketingRes)) { if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.error(deleteMarketingRes?.message as string); toast.error(deleteMarketingRes?.message as string);
} }
setIsLoading(false); setIsLoading(false);
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { import {
DeliveryOrderProductFormValues, DeliveryOrderProductFormValues,
DeliveryOrderProductSchema, DeliveryOrderProductSchema,
} from './DeliverOrderProduct.schema'; } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import Button from '@/components/Button'; import Button from '@/components/Button';
@@ -3,10 +3,10 @@ import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer'; import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper'; import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path'; import { format } from 'path';
import { date } from 'yup'; import { date } from 'yup';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
interface DeliveryOrderExportProps { interface DeliveryOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -3,8 +3,8 @@ import { Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer'; import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
interface SalesOrderExportProps { interface SalesOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -306,7 +306,6 @@ const SupplierForm = ({
label='Hatchery' label='Hatchery'
value={hatcheryOptionsValues} value={hatcheryOptionsValues}
onChange={(val) => { onChange={(val) => {
console.log(val); // pastikan val = array of { value, label }
setHatcheryOptionValues(val as OptionType[]); setHatcheryOptionValues(val as OptionType[]);
}} }}
isError={ isError={
@@ -7,13 +7,16 @@ import { formatNumber } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import ChickinFormView from './tabs/ChickinFormView';
import ChickinLogsView from './tabs/ChickLogsView';
import { useState } from 'react'; import { useState } from 'react';
import ApprovalSteps, { import ApprovalSteps, {
useApprovalSteps, useApprovalSteps,
} from '@/components/pages/ApprovalSteps'; } from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_KANDANG_APPROVAL_LINE } from '@/config/approval-line'; import ChickinFormView from '@/components/pages/production/chickin/form/tabs/ChickinFormView';
import ChickinLogsView from '@/components/pages/production/chickin/form/tabs/ChickLogsView';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { Icon } from '@iconify/react';
import Badge from '@/components/Badge';
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
const ChickinFormKandang = ({ const ChickinFormKandang = ({
formType = 'add', formType = 'add',
initialValues, initialValues,
@@ -23,7 +26,7 @@ const ChickinFormKandang = ({
initialValues: ProjectFlockKandang; initialValues: ProjectFlockKandang;
afterSubmit?: () => void; afterSubmit?: () => void;
}) => { }) => {
const [activeTabId, setActiveTabId] = useState<string>('formChickIn'); const [openChickin, setOpenChickin] = useState<boolean>(false);
const { const {
approvals, approvals,
@@ -31,114 +34,154 @@ const ChickinFormKandang = ({
refresh: refreshApprovals, refresh: refreshApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.approval, latestApproval: initialValues?.approval,
approvalLines: PROJECT_FLOCK_KANDANG_APPROVAL_LINE, approvalLines: CHICKINS_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCK_KANDANGS', moduleName: 'CHICKINS',
moduleId: initialValues?.id.toString() ?? '', moduleId: initialValues?.id.toString() ?? '',
}); });
const afterSubmitFormChickin = () => { const afterSubmitFormChickin = () => {
setActiveTabId('logsChickIn'); setOpenChickin(true);
afterSubmit && afterSubmit(); afterSubmit && afterSubmit();
refreshApprovals(); refreshApprovals();
}; };
return ( return (
<div className='flex flex-col gap-4'> <>
<FormHeader <DrawerHeader
title={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`} subtitle={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`} leftIcon='mdi:arrow-left'
leftIconHref={`/production/project-flock/detail?projectFlockId=${initialValues?.project_flock?.id}`}
/> />
{approvals && !approvalsLoading && ( {/* Informasi Kandang */}
<ApprovalSteps approvals={approvals} /> <div className='divider'></div>
)} <div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Kandang</h2>
<Card {approvals && !approvalsLoading && (
title='Informasi Kandang' <div className='mb-3 text-sm'>
className={{ <ApprovalSteps approvals={approvals} />
wrapper: 'w-full bg-white mt-4', </div>
}} )}
>
<Table<Kandang> {/* Badge Row */}
emptyContent={ <div className='flex flex-row gap-2'>
<div className='w-full p-5 text-center'> <Badge
<span className='text-lg opacity-50'> variant='soft'
Informasi Kandang belum tersedia... color='success'
</span> className={{
</div> badge: 'rounded-lg px-2',
} }}
data={[initialValues?.kandang]} >
columns={[ <Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
{ Aktif
header: 'Area', </Badge>
accessorFn: () => initialValues?.project_flock?.area.name || '-', <div className='divider divider-horizontal p-0 m-0'></div>
}, <Badge
{ color='neutral'
header: 'Lokasi', variant='soft'
accessorFn: () => className={{ badge: 'rounded-lg px-2' }}
initialValues?.project_flock?.location.name || '-', >
}, <Icon icon='mdi:home' width={12} height={12} />
{ {` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`}
header: 'Flock', </Badge>
accessorFn: () => initialValues?.project_flock?.flock_name || '-', </div>
},
{ {/* Information Grid */}
header: 'Kandang', <div className='grid grid-cols-3 gap-4'>
accessorFn: (row) => row?.name || '-', {/* Area */}
}, <div
{ className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
header: 'Kapasitas', relative
accessorFn: (row) => before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
(row?.capacity && formatNumber(row?.capacity)) || '-', >
}, <Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
{ </div>
header: 'Penanggung Jawab', <div className='col-span-2'>
accessorFn: (row) => row?.pic?.name || '-', {initialValues.project_flock.area.name}
}, </div>
]}
className={{ {/* Lokasi */}
tableWrapperClassName: 'overflow-x-auto min-h-full!', <div
tableClassName: 'font-inter w-full table-auto min-h-full!', className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
headerRowClassName: 'border-b border-b-gray-200', relative
headerColumnClassName: before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', >
bodyRowClassName: 'border-b border-b-gray-200', <Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
bodyColumnClassName: </div>
'px-6 py-3 last:flex last:flex-row last:justify-end', <div className='col-span-2'>
paginationClassName: 'hidden', {initialValues.project_flock?.location.name}
}} </div>
{/* Kandang */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
</div>
<div className='col-span-2'>{initialValues.kandang.name}</div>
{/* Jumlah DOC */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah DOC
</div>
<div className='col-span-2'>
{formatNumber(
initialValues.chickins?.reduce(
(total, chickin) => total + chickin.usage_qty,
0
) ?? 0
)}{' '}
Ekor
</div>
</div>
</div>
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Chick In</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color={'success'} />{' '}
Perlu Chick In ({initialValues.available_qtys?.length ?? 0})
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => setOpenChickin(!openChickin)}
>
{`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`}
<Icon
icon={`mdi:${openChickin ? 'eye' : 'eye-off'}`}
width={12}
height={12}
/>
</Badge>
</div>
</div>
{openChickin && (
<ChickinLogsView
initialValues={initialValues}
afterSubmit={afterSubmitFormChickin}
/> />
</Card> )}
<Tabs <ChickinFormView
className='bg-white p-2' initialValues={initialValues}
onTabChange={setActiveTabId} formType={formType}
activeTabId={activeTabId} afterSubmit={afterSubmitFormChickin}
tabs={[
{
id: 'formChickIn',
label: 'Tambah Chick In',
content: (
<ChickinFormView
initialValues={initialValues}
formType={formType}
afterSubmit={afterSubmitFormChickin}
/>
),
},
{
content: (
<ChickinLogsView
initialValues={initialValues}
afterSubmit={afterSubmitFormChickin}
/>
),
id: 'logsChickIn',
label: 'Riwayat Chick In',
},
]}
variant='lifted'
/> />
</div> </>
); );
}; };
@@ -2,17 +2,12 @@ import Alert from '@/components/Alert';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin'; import { ChickinApi } from '@/services/api/production/chickin';
import { import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
Chickin,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useState } from 'react'; import { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -54,105 +49,120 @@ const ChickinLogsView = ({
return ( return (
<> <>
<Card <div className='px-4 pb-4 flex flex-col gap-4'>
title='Riwayat Chick In' {/* Card List Chickin Logs */}
className={{ {(initialValues?.chickins || []).length === 0 ? (
wrapper: 'w-full bg-white', <div className='w-full p-8 text-center'>
}} <span className='text-lg opacity-50'>
> Belum ada riwayat Chick In...
<div className='flex flex-row justify-start gap-3 mt-3'> </span>
{initialValues?.approval?.step_number == 1 && ( </div>
<Button ) : (
color='success' (initialValues?.chickins || []).map((chickin, index) => {
variant='outline' const isApproved = chickin.usage_qty !== 0;
onClick={handleClickApprove} const isPending = chickin.pending_usage_qty !== 0;
> const quantity = isApproved
<Icon width={24} height={24} icon='material-symbols:check' /> ? chickin.usage_qty
Approve : isPending
</Button> ? chickin.pending_usage_qty
)} : 0;
</div>
<Table<Chickin> return (
data={initialValues?.chickins || []} <Card
columns={[ key={chickin.id || index}
{ variant='bordered'
header: '#', className={{
cell: (props) => props.row.index + 1, wrapper: 'w-full',
}, body: 'p-3',
{ }}
accessorFn: (row) => row.chick_in_date, >
header: 'Tanggal Chick In', <div className='flex flex-col gap-4'>
cell: (props) => { {/* Header with Status Badge */}
return formatDate(props.getValue() as string, 'DD MMM YYYY'); <div className='flex flex-row justify-between items-center'>
}, <div className='text-lg font-semibold'>
}, Chick In #{index + 1}
{ </div>
accessorFn: (row) => row.product_warehouse?.warehouse?.name, <PillBadge
header: 'Kandang', content={
}, isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
{ }
accessorFn: (row) => row.product_warehouse?.product?.name, color={
header: 'Produk', isApproved ? 'green' : isPending ? 'yellow' : 'gray'
}, }
{ />
accessorFn: (row) => row.usage_qty ?? row.pending_usage_qty, </div>
header: 'Jumlah Chick In',
cell: (props) => { {/* Tanggal Chick In */}
if (props.row.original.usage_qty != 0) { <div className='flex flex-row justify-between items-center'>
return formatNumber(props.row.original.usage_qty); <div className='flex flex-row gap-2 items-center text-gray-400'>
} else if (props.row.original.pending_usage_qty != 0) { <Icon icon={'mdi:calendar'} width={14} height={14} />{' '}
return formatNumber(props.row.original.pending_usage_qty); <span>Tanggal Chick In</span>
} else { </div>
return '-'; <div className='text-end text-gray-500'>
} {formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
}, </div>
}, </div>
{
accessorFn: (row) => row.pending_usage_qty, {/* Kandang */}
header: 'Status', <div className='flex flex-row justify-between items-center'>
cell: (props) => { <div className='flex flex-row gap-2 items-center text-gray-400'>
return ( <Icon icon={'mdi:home'} width={14} height={14} />{' '}
<PillBadge <span>Kandang</span>
content={ </div>
props.row.original.usage_qty !== 0 <div className='text-end text-gray-500'>
? 'Disetujui' {chickin.product_warehouse?.warehouse?.name || '-'}
: props.row.original.pending_usage_qty !== 0 </div>
? 'Pending' </div>
: '-'
} {/* Produk */}
color={ <div className='flex flex-row justify-between items-center'>
props.row.original.usage_qty !== 0 <div className='flex flex-row gap-2 items-center text-gray-400'>
? 'green' <Icon
: props.row.original.pending_usage_qty !== 0 icon={'mdi:package-variant'}
? 'yellow' width={14}
: 'gray' height={14}
} />{' '}
/> <span>Produk</span>
); </div>
}, <div className='text-end text-gray-500'>
}, {chickin.product_warehouse?.product?.name || '-'}
]} </div>
className={{ </div>
containerClassName: cn({
'mb-20': initialValues?.chickins?.length === 0, {/* Jumlah Chick In */}
}), <div className='flex flex-row justify-between items-center'>
tableWrapperClassName: 'overflow-x-auto min-h-full!', <div className='flex flex-row gap-2 items-center text-gray-400'>
tableClassName: 'font-inter w-full table-auto min-h-full!', <Icon icon={'mdi:counter'} width={14} height={14} />{' '}
headerRowClassName: 'border-b border-b-gray-200', <span>Jumlah Chick In</span>
headerColumnClassName: </div>
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', <div className='text-end text-gray-500 font-semibold'>
bodyRowClassName: 'border-b border-b-gray-200', {quantity > 0 ? `${formatNumber(quantity)} Ekor` : '-'}
bodyColumnClassName: </div>
'px-6 py-3 last:flex last:flex-row last:justify-end', </div>
paginationClassName: 'hidden', </div>
}} </Card>
/> );
})
)}
{initialValues?.approval?.step_number <= 2 && (
<Button
color='success'
onClick={handleClickApprove}
className='w-full'
>
<Icon width={24} height={24} icon='material-symbols:check' />
Approve Semua Chick In
</Button>
)}
{chickinErrorMessage && ( {chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}> <div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert> <Alert color='error'>{chickinErrorMessage}</Alert>
</div> </div>
)} )}
</Card> </div>
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type='success' type='success'
@@ -20,6 +20,7 @@ import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandan
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { Icon } from '@iconify/react';
const ChickinFormView = ({ const ChickinFormView = ({
initialValues, initialValues,
@@ -118,106 +119,142 @@ const ChickinFormView = ({
return ( return (
<form <form
className='flex flex-col gap-4' className='flex flex-col gap-4 p-4'
onReset={() => { onReset={() => {
handleReset(); handleReset();
}} }}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
> >
<Card {(formik.values.chickin_requests || []).map((chickinRequest, index) => {
title='Informasi Chick In DOC' const availableQty = initialValues?.available_qtys?.find(
className={{ (availableQty) =>
wrapper: 'w-full bg-white', availableQty.product_warehouse.id ===
}} chickinRequest.product_warehouse_id
> );
<Table<ChickinRequestFormValues> return (
data={formik.values.chickin_requests || []} <Card
columns={[ key={index}
{ // title={`${formatNumber(availableQty?.available_qty ?? 0)} Ekor - ${availableQty?.product_warehouse?.product?.name}`}
accessorFn: (row) => row.chick_in_date, variant='bordered'
header: 'Tanggal Chick In', size='sm'
cell(props) { className={{
return ( wrapper: 'w-full',
<DateInput body: 'p-3',
className={{ }}
wrapper: 'w-fit', >
inputWrapper: 'bg-white', <div className='flex flex-row justify-between items-center'>
}} <div className='text-lg font-semibold'>
name={`chickin_requests[${props.row.index}].chick_in_date`} {formatNumber(availableQty?.available_qty ?? 0)} Ekor -{' '}
value={ {availableQty?.product_warehouse?.product?.name}
formik.values.chickin_requests[props.row.index] </div>
?.chick_in_date as string {chickinRequest.chick_in_date && (
} <Icon
onChange={formik.handleChange} icon='mdi:check-circle-outline'
/> color='success'
); className='text-success'
}, width={20}
}, height={20}
{ />
accessorFn: (row) => row.product_warehouse_id, )}
header: 'Produk',
cell(props) {
const availableQty = initialValues?.available_qtys?.find(
(availableQty) =>
availableQty.product_warehouse.id ===
props.row.original.product_warehouse_id
);
return (
<div>{availableQty?.product_warehouse?.product?.name}</div>
);
},
},
{
accessorFn: (row) => row.product_warehouse_id,
header: 'Jumlah (ekor)',
cell(props) {
const availableQty = initialValues?.available_qtys?.find(
(availableQty) =>
availableQty.product_warehouse.id ===
props.row.original.product_warehouse_id
);
return (
<div>
{availableQty?.available_qty
? formatNumber(availableQty?.available_qty)
: '-'}
</div>
);
},
},
]}
className={{
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-2 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
emptyContent={
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Isi persediaan DOC untuk kandang belum tersedia...
</span>
</div> </div>
} <DateInput
/> className={{
</Card> wrapper: 'w-full',
<div className='flex flex-row justify-center gap-3'> inputWrapper: 'bg-white',
<Button type='reset' color='warning' disabled={formik.isSubmitting}> }}
Reset label='Tanggal Chick In'
</Button> name={`chickin_requests[${index}].chick_in_date`}
value={chickinRequest.chick_in_date}
onChange={formik.handleChange}
/>
</Card>
);
})}
{/* <Table<ChickinRequestFormValues>
data={formik.values.chickin_requests || []}
columns={[
{
accessorFn: (row) => row.chick_in_date,
header: 'Tanggal Chick In',
cell(props) {
return (
<DateInput
className={{
wrapper: 'w-fit',
inputWrapper: 'bg-white',
}}
name={`chickin_requests[${props.row.index}].chick_in_date`}
value={
formik.values.chickin_requests[props.row.index]
?.chick_in_date as string
}
onChange={formik.handleChange}
/>
);
},
},
{
accessorFn: (row) => row.product_warehouse_id,
header: 'Produk',
cell(props) {
const availableQty = initialValues?.available_qtys?.find(
(availableQty) =>
availableQty.product_warehouse.id ===
props.row.original.product_warehouse_id
);
return (
<div>{availableQty?.product_warehouse?.product?.name}</div>
);
},
},
{
accessorFn: (row) => row.product_warehouse_id,
header: 'Jumlah (ekor)',
cell(props) {
const availableQty = initialValues?.available_qtys?.find(
(availableQty) =>
availableQty.product_warehouse.id ===
props.row.original.product_warehouse_id
);
return (
<div>
{availableQty?.available_qty
? formatNumber(availableQty?.available_qty)
: '-'}
</div>
);
},
},
]}
className={{
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-2 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
emptyContent={
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Isi persediaan DOC untuk kandang belum tersedia...
</span>
</div>
}
/> */}
{formik.values.chickin_requests?.length > 0 && (
<Button <Button
type='submit' type='submit'
color='primary' color='primary'
disabled={!formik.isValid || formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
> >
Submit <Icon icon='mdi:checkbox-marked-outline' width={24} height={24} />
Chick In
</Button> </Button>
</div> )}
{chickinErrorMessage && ( {chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}> <div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert> <Alert color='error'>{chickinErrorMessage}</Alert>
@@ -1,6 +1,8 @@
'use client'; 'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button'; import Button from '@/components/Button';
import FloatingActionsButton from '@/components/FloatingActionsButton';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
@@ -8,23 +10,18 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
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 } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data'; import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { import { ProjectFlock } from '@/types/api/production/project-flock';
ProjectFlockApprovalPayload,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, SortingState } from '@tanstack/react-table'; import { CellContext, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useState } from 'react'; import { useRouter } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -98,7 +95,7 @@ const RowOptionsMenu = ({
); );
}; };
const ProjectFlockTable = () => { const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -123,8 +120,9 @@ const ProjectFlockTable = () => {
periodFilter: 'period', periodFilter: 'period',
}, },
}); });
const router = useRouter();
// State // ===== State =====
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection) const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id]) .filter((id) => rowSelection[id])
@@ -151,14 +149,15 @@ const ProjectFlockTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
// Fetch Data // ===== Fetch Data =====
const { const {
data: projectFlocks, data: projectFlocks,
isLoading, isLoading,
mutate: refreshProjectFlocks, mutate: refreshProjectFlocks,
} = useSWR( } = useSWR(
`${ProjectFlockApi.basePath}${getTableFilterQueryString()}`, `${ProjectFlockApi.basePath}${getTableFilterQueryString()}`,
ProjectFlockApi.getAllFetcher ProjectFlockApi.getAllFetcher,
{ revalidateOnMount: true }
); );
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({
@@ -191,7 +190,7 @@ const ProjectFlockTable = () => {
KandangApi.getAllFetcher KandangApi.getAllFetcher
); );
// Data to Options Mapping // ===== Data to Options Mapping ======
const optionsArea = isResponseSuccess(areas) const optionsArea = isResponseSuccess(areas)
? areas?.data.map((area) => ({ ? areas?.data.map((area) => ({
value: area.id, value: area.id,
@@ -211,7 +210,7 @@ const ProjectFlockTable = () => {
})) }))
: []; : [];
// Handler // ====== HANDLER ======
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType; const newVal = val as OptionType;
setPageSize(newVal.value as number); setPageSize(newVal.value as number);
@@ -219,17 +218,17 @@ const ProjectFlockTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await ProjectFlockApi.delete(selectedProjectFlock?.id as number); await ProjectFlockApi.delete(selectedSingleRow?.id as number);
refreshProjectFlocks(); refreshProjectFlocks();
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Project Flock!'); toast.success('Successfully delete Project Flock!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
setRowSelection({});
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
const confirmApprovalHandler = async ( const confirmApprovalHandler = async (
notes: string, notes: string,
approvalAction: 'APPROVED' | 'REJECTED' approvalAction: 'APPROVED' | 'REJECTED'
@@ -259,22 +258,44 @@ const ProjectFlockTable = () => {
setIsApproveLoading(false); setIsApproveLoading(false);
}; };
// ====== EFFECT ======
useEffect(() => {
refreshProjectFlocks();
}, [refresh]);
// ====== MEMO ======
const selectedSingleRow: ProjectFlock | null | undefined = useMemo(() => {
return selectedRowIds.length === 1
? isResponseSuccess(projectFlocks)
? projectFlocks?.data.find((row) => row.id === selectedRowIds[0])
: null
: null;
}, [rowSelection]);
const canApprove = useMemo(() => {
if (!selectedSingleRow || isApproveLoading) return false;
const isPengajuan = selectedSingleRow.approval.step_number == 1;
const isNotRejected = selectedSingleRow.approval.action != 'REJECTED';
return isPengajuan && isNotRejected;
}, [selectedSingleRow, isApproveLoading]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='min-h-screen w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col justify-between items-end gap-2'> <div className='w-full flex flex-col justify-between items-end gap-2'>
<div className='flex flex-col sm:flex-row gap-3 w-full'> <div className='flex flex-col sm:flex-row gap-3 w-full'>
<Button <Button
href='/production/project-flock/add'
variant='outline'
color='primary' color='primary'
className='w-full sm:w-fit' className='w-full sm:w-fit'
href='/production/project-flock/add'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Tambah
</Button> </Button>
<Button {/* <Button
variant='outline' variant='outline'
color='success' color='success'
onClick={() => { onClick={() => {
@@ -299,7 +320,7 @@ const ProjectFlockTable = () => {
> >
<Icon icon='mdi:times' width={24} height={24} /> <Icon icon='mdi:times' width={24} height={24} />
Reject Reject
</Button> </Button> */}
<div className='ms-auto w-full sm:w-auto'> <div className='ms-auto w-full sm:w-auto'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
@@ -391,9 +412,7 @@ const ProjectFlockTable = () => {
id: 'select', id: 'select',
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter( const selectableRows = allRows;
(row) => row.original?.approval?.step_number == 1
);
const allSelected = const allSelected =
selectableRows.every((row) => row.getIsSelected()) && selectableRows.every((row) => row.getIsSelected()) &&
@@ -417,12 +436,6 @@ const ProjectFlockTable = () => {
checked={allSelected} checked={allSelected}
indeterminate={someSelected} indeterminate={someSelected}
onChange={toggleSelectableRows} onChange={toggleSelectableRows}
disabled={
isResponseSuccess(projectFlocks) &&
projectFlocks?.data?.filter(
(flock) => flock.approval.step_number == 1
).length == 0
}
/> />
</div> </div>
); );
@@ -431,14 +444,8 @@ const ProjectFlockTable = () => {
return ( return (
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={ checked={row.getIsSelected()}
row.getIsSelected() && disabled={!row.getCanSelect()}
row.original.approval.step_number == 1
}
disabled={
!row.getCanSelect() ||
row.original.approval.step_number != 1
}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
/> />
@@ -469,6 +476,40 @@ const ProjectFlockTable = () => {
{ {
accessorKey: 'approval.step_name', accessorKey: 'approval.step_name',
header: 'Status', header: 'Status',
cell: (props) => {
const approval = props.row.original.approval;
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
/>
{approval.step_name}
</Badge>
);
},
}, },
{ {
header: 'Kandang', header: 'Kandang',
@@ -496,51 +537,51 @@ const ProjectFlockTable = () => {
accessorKey: 'created_at', accessorKey: 'created_at',
header: 'Dibuat pada', header: 'Dibuat pada',
cell: (props) => cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString(), formatDate(props.row.original.created_at, 'MMM DD, YYYY'),
}, },
{ // {
header: 'Aksi', // header: 'Aksi',
cell: (props) => { // cell: (props) => {
const currentPageSize = // const currentPageSize =
props.table.getPaginationRowModel().rows.length; // props.table.getPaginationRowModel().rows.length;
const currentPageRows = // const currentPageRows =
props.table.getPaginationRowModel().flatRows; // props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex = // const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1; // currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = // const isLast2Rows =
currentRowRelativeIndex > currentPageSize - 2; // currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => { // const deleteClickHandler = () => {
setSelectedProjectFlock(props.row.original); // setSelectedProjectFlock(props.row.original);
deleteModal.openModal(); // deleteModal.openModal();
}; // };
return ( // return (
<> // <>
{currentPageSize > 2 && ( // {currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}> // <RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu // <RowOptionsMenu
type='dropdown' // type='dropdown'
props={props} // props={props}
deleteClickHandler={deleteClickHandler} // deleteClickHandler={deleteClickHandler}
/> // />
</RowDropdownOptions> // </RowDropdownOptions>
)} // )}
{currentPageSize <= 2 && ( // {currentPageSize <= 2 && (
<RowCollapseOptions> // <RowCollapseOptions>
<RowOptionsMenu // <RowOptionsMenu
type='collapse' // type='collapse'
props={props} // props={props}
deleteClickHandler={deleteClickHandler} // deleteClickHandler={deleteClickHandler}
/> // />
</RowCollapseOptions> // </RowCollapseOptions>
)} // )}
</> // </>
); // );
}, // },
}, // },
]} ]}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={ page={
@@ -576,6 +617,57 @@ const ProjectFlockTable = () => {
</div> </div>
</div> </div>
<FloatingActionsButton
actions={[
{
action: 'DETAIL',
icon: 'mdi:eye-outline',
label: 'Lihat Detail',
hidden: selectedRowIds.length !== 1,
onClick() {
router.push(
`/production/project-flock/detail?projectFlockId=${selectedRowIds[0]}`
);
setRowSelection({});
},
},
{
action: 'DELETE',
icon: 'material-symbols:delete-outline-rounded',
label: `Hapus data`,
hidden: selectedRowIds.length !== 1,
onClick: () => {
deleteModal.openModal();
},
},
]}
approvals={[
{
icon: 'material-symbols:check',
label: 'Approve',
action: 'APPROVED',
onClick: () => {
setApprovalAction('APPROVED');
confirmModal.openModal();
},
disabled: !canApprove,
},
{
icon: 'mdi:times',
label: 'Reject',
action: 'REJECTED',
onClick: () => {
setApprovalAction('REJECTED');
confirmModal.openModal();
},
},
]}
selectedRowIds={selectedRowIds}
onClose={() => {
setRowSelection({});
}}
/>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
@@ -10,7 +10,7 @@ import SelectInput, {
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper'; import { cn, formatDate, formatTitleCase } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production'; import { ProjectFlockKandangApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -21,6 +21,7 @@ import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { FormHeader } from '@/components/helper/form/FormHeader'; import { FormHeader } from '@/components/helper/form/FormHeader';
import Link from 'next/link';
const ProjectFlockChickinDetail = ({ const ProjectFlockChickinDetail = ({
projectFlockId, projectFlockId,
@@ -101,11 +102,26 @@ const ProjectFlockChickinDetail = ({
}, [projectFlockId, listProjectFlock]); }, [projectFlockId, listProjectFlock]);
return ( return (
<> <>
<FormHeader {/* Header */}
<div className='flex flex-row justify-between items-center px-4 py-4'>
<div className='flex flex-row items-center h-full gap-2'>
<Link
href={`/production/project-flock/detail?projectFlockId=${projectFlock?.id}`}
className='hover:text-gray-400'
>
<Icon icon='mdi:arrow-left' width={24} height={24} />
</Link>
<div className='divider divider-horizontal p-0 m-0'></div>
<div className='text-sm text-neutral'>
Chick In {projectFlock?.flock_name}
</div>
</div>
</div>
{/* <FormHeader
title={`Chick In ${projectFlock?.flock_name ?? 'Project Flock'}`} title={`Chick In ${projectFlock?.flock_name ?? 'Project Flock'}`}
backUrl='/production/project-flock' backUrl={`/production/project-flock/detail?projectFlockId=${projectFlock?.id}`}
/> /> */}
<div className='flex flex-col gap-4 w-full my-4'> {/* <div className='flex flex-col gap-4 w-full my-4'>
<div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'> <div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'>
<SelectInput <SelectInput
required required
@@ -145,8 +161,129 @@ const ProjectFlockChickinDetail = ({
} }
/> />
</div> </div>
</div> </div> */}
<Card {/* Informasi Umum */}
{projectFlock && (
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={
projectFlock.approval.step_number == 1
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
? 'error'
: undefined
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
projectFlock.approval.step_number == 1
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
? 'error'
: undefined
}
/>{' '}
{projectFlock.approval.step_name}
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:bookmark' width={12} height={12} />
{` ${formatTitleCase(projectFlock.category)}`}
</Badge>
</div>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:account' /> Submitted
</div>
<div className='col-span-2'>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:account-circle' width={14} height={14} />{' '}
{projectFlock.created_user.name}
</Badge>
</div>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon={'mdi:clock'} /> History
</div>
<div className='col-span-2'>
<Button variant='outline' className='py-1 text-sm'>
See History{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div>
{/* BARIS 1 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock.area.name}</div>
{/* BARIS 2 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock.location.name}</div>
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> FCR
</div>
<div className='col-span-2'>{projectFlock.fcr.name}</div>
{/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
Kategori
</div>
<div className='col-span-2'>
{formatTitleCase(projectFlock.category)}
</div>
</div>
</div>
</div>
)}
{/* <Card
title='Informasi Flock' title='Informasi Flock'
className={{ className={{
wrapper: 'w-full bg-white mb-3', wrapper: 'w-full bg-white mb-3',
@@ -231,8 +368,152 @@ const ProjectFlockChickinDetail = ({
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
/> />
</Card> </Card> */}
<Card {/* Card Kandangs */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Daftar Kandang</h2>
{isResponseSuccess(listProjectFlock) ? (
<>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'success'}
/>{' '}
Disetujui (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval?.step_number == 1
).length}
)
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
variant='soft'
color={'neutral'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'neutral'}
/>{' '}
Pengajuan (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval?.step_number == 2
).length}
)
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='error'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon
icon={`mdi:circle`}
width={12}
height={12}
color='error'
/>
Belum Chickin (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval == null
).length}
)
</Badge>
</div>
{/* Card Kandang */}
<Card
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-col gap-6'>
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.map((kandang) => (
<div
key={kandang.id}
className='flex flex-row justify-between items-center'
>
<div className='flex flex-row gap-2 items-center cursor-pointer text-gray-400'>
<Badge
variant='soft'
color={
kandang.approval
? kandang.approval.step_number == 1
? 'success'
: 'neutral'
: 'error'
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
kandang.approval
? kandang.approval.step_number == 1
? 'success'
: 'neutral'
: 'gray'
}
/>
</Badge>
<span className='font-semibold'>
{kandang.kandang.name}
</span>
</div>
<Button
variant='outline'
className='py-1 text-sm'
onClick={() => {
handleChickinClick(kandang);
}}
disabled={projectFlock?.approval?.step_number === 1}
>
Chick In{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div>
))}
</div>
</Card>
</>
) : (
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Pilih project flock terlebih dahulu...
</span>
</div>
)}
</div>
</div>
{/* <Card
title='Daftar Kandang' title='Daftar Kandang'
className={{ className={{
wrapper: 'w-full bg-white', wrapper: 'w-full bg-white',
@@ -351,7 +632,7 @@ const ProjectFlockChickinDetail = ({
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
/> />
</Card> </Card> */}
</> </>
); );
}; };
@@ -0,0 +1,305 @@
'use client';
import Button from '@/components/Button';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import Table from '@/components/Table';
import Badge from '@/components/Badge';
import { cn, formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { ProjectFlock } from '@/types/api/production/project-flock';
import {
ClosingExpense,
ProjectFlockKandang,
StockItem,
} from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import useSWR from 'swr';
import { ProjectFlockKandangApi } from '@/services/api/production/project-flock-kandang';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
const ProjectFlockClosingForm = ({
projectFlock,
projectFlockKandang,
}: {
projectFlock: ProjectFlock;
projectFlockKandang: ProjectFlockKandang;
}) => {
const router = useRouter();
const closeModal = useModal();
const isCanClose = projectFlock.approval?.step_number <= 2;
const [isClosingLoading, setIsClosingLoading] = useState(false);
const { data: closingData, isLoading } = useSWR(
`${ProjectFlockKandangApi.basePath}/${projectFlockKandang.id}/closing`,
() => ProjectFlockKandangApi.checkClosing(projectFlockKandang.id)
);
const confirmationModalCloseClickHandler = async () => {
setIsClosingLoading(true);
const deleteProjectFlockRes = await ProjectFlockKandangApi.closing(
projectFlockKandang?.id as number,
{
closed_date: formatDate(new Date(), 'YYYY-MM-DD'),
action: isCanClose ? 'close' : 'unclose',
}
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push(`/production/project-flock`);
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
setIsClosingLoading(false);
closeModal.closeModal();
};
const errorStock = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0)
: true;
}, [closingData]);
const errorExpense = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.expenses.every((expense) => expense.step < 5)
: true;
}, [closingData]);
const isCanCloseValid = true;
return (
<>
<DrawerHeader
leftIcon='mdi:arrow-left'
leftIconHref={`/production/project-flock/detail?projectFlockId=${projectFlock.id}`}
subtitle={`Close ${projectFlock.flock_name}`}
></DrawerHeader>
{/* Informasi Kandang */}
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Kandang</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='success'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
Aktif
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:home' width={12} height={12} />
{` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`}
</Badge>
</div>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
{/* Area */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock.area?.name}</div>
{/* Lokasi */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock.location?.name}</div>
{/* Kandang */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
</div>
<div className='col-span-2'>{projectFlockKandang.kandang?.name}</div>
{/* Jumlah DOC */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah DOC
</div>
<div className='col-span-2'>
{formatNumber(
projectFlockKandang.chickins?.reduce(
(total, chickin) => total + chickin.usage_qty,
0
) ?? 0
)}{' '}
Ekor
</div>
</div>
</div>
{/* Table Biaya */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Biaya</h2>
<Table<ClosingExpense>
data={
isResponseSuccess(closingData) ? closingData.data?.expenses : []
}
columns={[
{
header: 'PO Number',
accessorKey: 'po_number',
},
{
header: 'Total',
accessorKey: 'total',
},
{
header: 'Status',
accessorKey: 'status',
cell(props) {
return (
<Badge
className={{
badge: 'rounded-lg',
}}
variant='soft'
color={
props.row.original.step < 5
? props.row.original.step == 1
? 'neutral'
: 'success'
: 'error'
}
>
{formatTitleCase(props.row.original.step_name)}
</Badge>
);
},
},
]}
className={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{/* {errorExpense && (
<div className='text-center text-error text-sm'>
*Pastikan semua biaya sudah selesai sebelum melakukan closing.
</div>
)} */}
</div>
{/* Table Persediaan Gudang */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Persediaan Gudang</h2>
<Table<StockItem>
data={
isResponseSuccess(closingData)
? closingData.data?.stock_remaining
: []
}
columns={[
{
header: 'Product',
accessorKey: 'product_name',
},
{
header: 'Kategori',
accessorKey: 'product_category',
},
{
header: 'Quantity',
accessorKey: 'quantity',
},
{
header: 'UOM',
accessorKey: 'uom',
},
]}
className={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{/* {errorStock && (
<div className='text-center text-error text-sm'>
*Masih ada sisa stock yang belum dihabiskan.
</div>
)} */}
</div>
<div className='p-4 mt-6'>
<Button
className='w-full'
color='error'
isLoading={isLoading}
disabled={!isCanCloseValid}
onClick={() => closeModal.openModal()}
>
<Icon icon='mdi:checkbox-marked-circle-outline' />{' '}
{isCanClose ? 'Close' : 'Unclose'}
</Button>
</div>
<ConfirmationModal
ref={closeModal.ref}
type='error'
text={
isCanClose
? 'Apakah kamu yakin ingin mengakhiri project ini ? *Pastikan persediaan produk di gudang terkait sudah kosong, dan BOP sudah selesai'
: 'Apakah kamu yakin ingin membuka kembali project ini ? *Project ini akan kembali ke status aktif'
}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isClosingLoading,
onClick: confirmationModalCloseClickHandler,
}}
/>
</>
);
};
export default ProjectFlockClosingForm;
@@ -0,0 +1,476 @@
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import {
formatCurrency,
formatDate,
formatNumber,
formatTitleCase,
} from '@/lib/helper';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import toast from 'react-hot-toast';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import {
PROJECT_FLOCK_APPROVAL_LINE,
PROJECT_FLOCK_KANDANGS_APPROVAL_LINE,
} from '@/config/approval-line';
import useSWR from 'swr';
import { ProjectFlockKandangApi } from '@/services/api/production';
const ProjectFlockDetail = ({
projectFlock,
}: {
projectFlock: ProjectFlock;
}) => {
const router = useRouter();
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [openBudgets, setOpenBudget] = useState(false);
const [selectedKandangId, setSelectedKamdangId] = useState<string | null>(
null
);
const selectedKandang = projectFlock.kandangs?.find(
(kandang) => kandang.id === Number(selectedKandangId)
);
const { data: projectFlockKandang, isLoading: projectFlockKandangLoading } =
useSWR(
selectedKandangId
? `${ProjectFlockKandangApi.basePath}/get-detail/${selectedKandangId}`
: null,
selectedKandangId
? () =>
ProjectFlockKandangApi.getSingle(
Number(selectedKandang?.project_flock_kandang_id)
)
: null
);
const {
approvals,
isLoading: approvalsLoading,
refresh: refreshApprovals,
} = useApprovalSteps({
latestApproval: projectFlock?.approval,
approvalLines: PROJECT_FLOCK_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCKS',
moduleId: projectFlock?.id.toString() ?? '',
});
const { approvals: kandangApprovals, isLoading: kandangApprovalsLoading } =
useApprovalSteps({
latestApproval:
selectedKandangId && isResponseSuccess(projectFlockKandang)
? projectFlockKandang?.data?.approval
: undefined,
approvalLines: PROJECT_FLOCK_KANDANGS_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCK_KANDANGS',
moduleId:
selectedKandangId && isResponseSuccess(projectFlockKandang)
? projectFlockKandang?.data?.id?.toString()
: '',
});
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteProjectFlockRes = await ProjectFlockApi.delete(
projectFlock?.id as number
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push('/production/project-flock');
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
setIsDeleteLoading(false);
};
return (
<>
<div className='h-full w-full flex flex-col gap-4'>
{/* Header */}
<DrawerHeader
leftIcon='mdi:close'
leftIconHref='/production/project-flock'
subtitle={`Created On ${formatDate(projectFlock.created_at, 'MMM DD, YYYY')}`}
>
<Link
href={`/production/project-flock/detail/edit?projectFlockId=${projectFlock.id}`}
className='p-0'
>
<Tooltip content='Edit' position='bottom'>
<Button variant='link' className='p-0 text-neutral'>
<Icon icon='mdi:square-edit-outline' width={20} height={20} />
</Button>
</Tooltip>
</Link>
<Button
variant='link'
className='p-0 text-error'
onClick={() => {
deleteModal.openModal();
}}
>
<Tooltip content='Hapus' position='bottom'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} />
</Tooltip>
</Button>
</DrawerHeader>
{/* Informasi Umum */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
{/* Status Approval */}
{approvals && !approvalsLoading && (
<div className='text-sm my-3'>
<ApprovalSteps approvals={approvals} />
</div>
)}
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
: undefined
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
: undefined
}
/>{' '}
{projectFlock.approval?.step_name}
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:bookmark' width={12} height={12} />
{` ${formatTitleCase(projectFlock.category ?? '')}`}
</Badge>
</div>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:account' /> Submitted
</div>
<div className='col-span-2'>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:account-circle' width={14} height={14} />{' '}
{projectFlock.created_user?.name}
</Badge>
</div>
{/* <div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon={'mdi:clock'} /> History
</div>
<div className='col-span-2'>
<Button variant='outline' className='py-1 text-sm'>
See History{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div> */}
{/* BARIS 1 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock?.area?.name}</div>
{/* BARIS 2 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock?.location?.name}</div>
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> FCR
</div>
<div className='col-span-2'>{projectFlock?.fcr?.name}</div>
{/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
Kategori
</div>
<div className='col-span-2'>
{formatTitleCase(projectFlock.category ?? '')}
</div>
</div>
</div>
</div>
{/* Kandang Aktif */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Kandang Aktif</h2>
{kandangApprovals && !kandangApprovalsLoading && (
<ApprovalSteps approvals={kandangApprovals} />
)}
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'success'}
/>{' '}
Kandang Aktif ({projectFlock.kandangs?.length})
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => {
setOpenBudget(!openBudgets);
}}
>
{` ${formatCurrency(
(projectFlock.project_budgets ?? []).reduce(
(acc, curr) => acc + curr.price * curr.qty,
0
)
)}`}
<Icon
icon={`mdi:${openBudgets ? 'eye' : 'eye-off'}`}
width={12}
height={12}
/>
</Badge>
</div>
{/* Card List Project Budgets */}
{openBudgets &&
(projectFlock.project_budgets ?? []).map((budget) => (
<Card
key={budget.id}
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-col gap-6'>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:tag'} width={14} height={14} />{' '}
<span>Jenis Produk</span>
</div>
<div className='text-end text-gray-500'>
{budget?.nonstock?.name}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:tag'} width={14} height={14} />{' '}
<span>Nama Satuan</span>
</div>
<div className='text-end text-gray-500'>
{budget?.nonstock?.uom?.name}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon
icon={'mdi:file-multiple'}
width={14}
height={14}
/>{' '}
<span>Jumlah Pembelian</span>
</div>
<div className='text-end text-gray-500'>
{formatNumber(budget.qty)}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:file'} width={14} height={14} />{' '}
<span>Harga Satuan</span>
</div>
<div className='text-end text-gray-500'>
{formatCurrency(budget.price)}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:calculator'} width={14} height={14} />{' '}
<span>Total Harga</span>
</div>
<div className='text-end text-gray-500'>
{formatCurrency(budget.price * budget.qty)}
</div>
</div>
</div>
</Card>
))}
{/* Card Kandangs */}
<Card
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<RadioGroup
name='gender'
className={{
radioWrapper: 'grid grid-cols-1 gap-6',
}}
onChange={(e) => setSelectedKamdangId(e.target.value)}
value={selectedKandangId?.toString()}
size='md'
color='neutral'
disabled={projectFlock?.approval?.step_number == 1}
>
{projectFlock.kandangs?.map((kandang) => (
<div
key={kandang.id}
className={`grid grid-cols-2 gap-6 cursor-pointer hover:text-gray-800`}
onClick={() =>
projectFlock?.approval?.step_number > 1 &&
setSelectedKamdangId(kandang?.id?.toString())
}
>
<RadioGroupItem
value={kandang?.id?.toString()}
label={kandang?.name}
disabled={projectFlock?.approval?.step_number == 1}
/>
<div className='text-end'>
<Badge
className={{
badge: 'rounded-lg',
}}
>
Kapasitas {kandang?.capacity} Ekor
</Badge>
</div>
</div>
))}
</RadioGroup>
</Card>
<div className='grid grid-cols-4 gap-3'>
<Link
href={`/production/project-flock/chickin/add/kandang?projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}&projectFlockId=${projectFlock.id}`}
className='m-0 p-0'
>
<Button
className='w-full px-2 py-1 text-sm'
variant='outline'
color='success'
disabled={
!selectedKandangId ||
projectFlock?.approval?.step_number == 1
}
>
Chickin <Icon icon='mdi:checkbox-marked-outline' />
</Button>
</Link>
<Link
href={`/production/project-flock/closing?projectFlockId=${projectFlock.id}&projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}`}
className='m-0 p-0'
>
<Button
className='w-full px-2 py-1 text-sm'
variant='outline'
color='error'
disabled={
!selectedKandangId ||
projectFlock?.approval?.step_number == 1
}
>
Close <Icon icon='mdi:checkbox-marked-circle-outline' />
</Button>
</Link>
</div>
</div>
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${projectFlock?.flock_name} - ${projectFlock?.area?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default ProjectFlockDetail;
@@ -1,52 +1,124 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProjectFlockFormSchema = Yup.object({ type ProjectFlockFormSchemaType = {
// Flock flock: {
flock: Yup.object({ value: number | string;
value: Yup.number().required('ID Flock wajib diisi!'), label: string;
label: Yup.string().required('Nama Flock wajib diisi!'), } | null;
}).nullable(), flock_name: string;
flock_name: Yup.string().required('Nama Flock wajib diisi!'), area: {
value: number | string;
label: string;
} | null;
area_id: number;
category_option: {
value: string;
label: string;
} | null;
category: string;
fcr: {
value: number | string;
label: string;
} | null;
fcr_id: number;
location: {
value: number | string;
label: string;
} | null;
location_id: number;
kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[];
};
// Area export type ProjectFlockBudgetsSchemaType = {
area: Yup.object({ nonstock: {
value: Yup.number().required('ID Area wajib diisi!'), value: number | string;
label: Yup.string().required('Nama Area wajib diisi!'), label: string;
}).nullable(), } | null;
area_id: Yup.number() nonstock_id: number | string;
.min(1, 'Area wajib diisi!') qty: number | string;
.required('Area wajib diisi!'), price: number | string;
total_price: number | string;
};
// Kategori export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSchemaType> =
category_option: Yup.object({ Yup.object({
value: Yup.string().required('Nilai Kategori wajib diisi!'), nonstock: Yup.object({
label: Yup.string().required('Label Kategori wajib diisi!'), value: Yup.number().required('ID Nonstock wajib diisi!'),
}).nullable(), label: Yup.string().required('Nama Nonstock wajib diisi!'),
category: Yup.string() }).required('Nonstock wajib diisi!'),
.oneOf(['GROWING', 'LAYING'], 'Kategori wajib diisi!') nonstock_id: Yup.number()
.required('Kategori wajib diisi!'), .min(1, 'Nonstock wajib diisi!')
.required('Nonstock wajib diisi!'),
qty: Yup.number()
.typeError('Jumlah harus berupa angka!')
.min(1, 'Jumlah minimal 1!')
.required('Jumlah wajib diisi!'),
price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
total_price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
});
// FCR export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
fcr: Yup.object({ Yup.object({
value: Yup.number().required('ID FCR wajib diisi!'), // Flock
label: Yup.string().required('Nama FCR wajib diisi!'), flock: Yup.object({
}).nullable(), value: Yup.number().required('ID Flock wajib diisi!'),
fcr_id: Yup.number().min(1, 'FCR wajib diisi!').required('FCR wajib diisi!'), label: Yup.string().required('Nama Flock wajib diisi!'),
}).nullable(),
flock_name: Yup.string().required('Nama Flock wajib diisi!'),
// Location // Area
location: Yup.object({ area: Yup.object({
value: Yup.number().required('ID Lokasi wajib diisi!'), value: Yup.number().required('ID Area wajib diisi!'),
label: Yup.string().required('Nama Lokasi wajib diisi!'), label: Yup.string().required('Nama Area wajib diisi!'),
}).nullable(), }).nullable(),
location_id: Yup.number() area_id: Yup.number()
.min(1, 'Lokasi wajib diisi!') .min(1, 'Area wajib diisi!')
.required('Lokasi wajib diisi!'), .required('Area wajib diisi!'),
kandang_ids: Yup.array() // Kategori
.of(Yup.number().typeError('Kandang tidak valid!')) category_option: Yup.object({
.min(1, 'Minimal harus ada 1 kandang!') value: Yup.string().required('Nilai Kategori wajib diisi!'),
.required('Kandang wajib diisi!'), label: Yup.string().required('Label Kategori wajib diisi!'),
}); }).nullable(),
category: Yup.string()
.oneOf(['GROWING', 'LAYING'], 'Kategori wajib diisi!')
.required('Kategori wajib diisi!'),
// FCR
fcr: Yup.object({
value: Yup.number().required('ID FCR wajib diisi!'),
label: Yup.string().required('Nama FCR wajib diisi!'),
}).nullable(),
fcr_id: Yup.number()
.min(1, 'FCR wajib diisi!')
.required('FCR wajib diisi!'),
// Location
location: Yup.object({
value: Yup.number().required('ID Lokasi wajib diisi!'),
label: Yup.string().required('Nama Lokasi wajib diisi!'),
}).nullable(),
location_id: Yup.number()
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'),
kandang_ids: Yup.array()
.of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!')
.required('Kandang wajib diisi!'),
project_budgets: Yup.array()
.of(ProjectFlockBudgetsSchema)
.min(1, 'Minimal harus ada 1 data budget!')
.required('Data budget wajib diisi!'),
});
export type ProjectFlockFormValues = Yup.InferType< export type ProjectFlockFormValues = Yup.InferType<
typeof ProjectFlockFormSchema typeof ProjectFlockFormSchema
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
'use client'; 'use client';
import Badge from '@/components/Badge';
import Card from '@/components/Card';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
@@ -9,6 +11,7 @@ import {
ProjectFlock, ProjectFlock,
ProjectFlockPeriods, ProjectFlockPeriods,
} from '@/types/api/production/project-flock'; } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import { OnChangeFn, Row } from '@tanstack/react-table'; import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -29,163 +32,119 @@ const ProjectFlockKandangTable = ({
initialValues?: ProjectFlock; initialValues?: ProjectFlock;
formType: 'add' | 'edit' | 'detail'; formType: 'add' | 'edit' | 'detail';
}) => { }) => {
const initialKandangIdSet = useMemo(() => { // Fungsi untuk menangani perubahan checkbox
return initialValues?.kandangs.map((k) => k.id) ?? []; const handleCheckboxChange = (kandang: Kandang, isChecked: boolean) => {
}, [initialValues]); // Hanya izinkan perubahan jika tidak dalam mode 'detail'
const isRowEnabled = (row: Row<Kandang>) => { if (formType === 'detail') return;
const isDisabled =
!initialKandangIdSet.includes(row.original.id) && // Pastikan kandang.id ada dan tidak null/undefined
(row.original.status == 'ACTIVE' || if (kandang.id === undefined) return;
row.original.status == 'PENGAJUAN' ||
formType == 'detail'); const kandangIdString = kandang.id.toString();
return !isDisabled;
setRowSelection((prev) => {
const newSelection = { ...prev };
if (isChecked) {
newSelection[kandangIdString] = true;
} else {
delete newSelection[kandangIdString];
}
return newSelection;
});
}; };
return ( return (
<> <>
<Table<Kandang> {listKandang.length > 0 ? (
data={listKandang} <>
columns={[ {/* ... Bagian Badge Status ... */}
{ <div className='flex flex-row mb-4'>
id: 'select', <Badge
header: ({ table }) => { variant='soft'
const allRows = table.getRowModel().rows; color='primary'
// 1. Filter semua baris dengan logika yang sama persis seperti di cell className={{
const selectableRows = allRows.filter(isRowEnabled); badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tersedia (
{
listKandang.filter((kandang) => kandang.status == 'NON_ACTIVE')
.length
}
)
</Badge>
<div className='divider divider-horizontal mx-1'></div>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tidak Tersedia (
{
listKandang.filter((kandang) => kandang.status != 'NON_ACTIVE')
.length
}
)
</Badge>
</div>
{/* --- */}
<Card
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-4',
}}
>
<div className='flex flex-col gap-4 w-full'>
{listKandang.map((kandang, index) => {
const kandangIdString =
kandang.id?.toString() ?? `temp-${index}`;
// 2. Cek apakah SEMUA baris yang BISA DIPILIH sudah terpilih const isSelected =
const allSelected = !!rowSelection[kandangIdString] ||
selectableRows.length > 0 && (kandang.id !== undefined &&
selectableRows.every((row) => row.getIsSelected()); selectedIds.includes(kandang.id));
// 3. Cek apakah BEBERAPA baris yang BISA DIPILIH sudah terpilih const isDisabled =
const someSelected = formType == 'detail' || kandang.status != 'NON_ACTIVE';
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
// 4. Fungsi toggle HANYA akan mentoggle baris yang BISA DIPILIH return (
const toggleSelectableRows = () => { <div key={index} className='flex flex-row justify-between'>
const shouldSelect = !allSelected; <CheckboxInput
selectableRows.forEach((row) => name={`kandang-${kandang.id}`} // Nama unik untuk setiap checkbox
row.toggleSelected(shouldSelect) label={kandang.name}
checked={isSelected}
disabled={isDisabled}
onChange={(e) =>
handleCheckboxChange(kandang, e.currentTarget.checked)
}
/>
<Badge
variant='soft'
color={
kandang.status == 'NON_ACTIVE' ? 'primary' : 'neutral'
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
{kandang.status != 'NON_ACTIVE' && 'Tidak'} Tersedia
</Badge>
</div>
); );
}; })}
</div>
return ( </Card>
<div className='w-full flex flex-row justify-center'> </>
<CheckboxInput ) : (
name='allRow' <div className='text-center py-4 text-gray-400'>
checked={allSelected} Pilih lokasi terlebih dahulu
indeterminate={someSelected} </div>
onChange={toggleSelectableRows} )}
disabled={
selectableRows.length === 0 || formType == 'detail'
}
/>
</div>
);
},
cell: ({ row }) => {
return (
<CheckboxInput
name='row'
checked={
(row.getIsSelected() &&
(row.original.status == 'NON_ACTIVE' ||
row.original.status == 'PENGAJUAN')) ||
(selectedIds && selectedIds.includes(row.original.id))
}
disabled={
formType == 'detail' ||
(!initialKandangIdSet.includes(row.original.id) &&
(row.original.status == 'ACTIVE' ||
row.original.status == 'PENGAJUAN'))
}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
);
},
},
{
accessorFn: (row) => row.name,
header: 'Kandang',
},
{
accessorFn: (row) => row.status,
header: 'Status',
cell: (props) => {
return (
<PillBadge
color={(() => {
switch (props.row.original.status) {
case 'ACTIVE':
return 'red';
case 'PENGAJUAN':
return 'green';
case 'NON_ACTIVE':
return 'blue';
default:
return 'gray';
}
})()}
content={props.row.original.status
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())}
/>
);
},
},
{
accessorFn: (row) => row.capacity,
header: 'Kapasitas',
},
{
accessorFn: (row) => row.location?.name,
header: 'Periode',
cell: (props) => {
console.log('listPeriods');
console.log(listPeriods);
const period =
listPeriods.length > 0
? listPeriods.find((p) => p.id == props.row.original.id)
: undefined;
const calcPeriod = period?.period == 0 ? 1 : period?.period;
const selected = props.row.getIsSelected();
const initPeriod = initialValues?.period;
return formType == 'detail'
? selected
? initPeriod
: '-'
: formType == 'add'
? (calcPeriod ?? '-')
: selected
? (initPeriod ?? '-')
: (calcPeriod ?? '-');
},
},
{
accessorFn: (row) => row.pic?.name,
header: 'Penanggung Jawab',
},
]}
className={{
containerClassName: cn({
'mb-20': listKandang?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
/>
</> </>
); );
}; };
@@ -35,33 +35,32 @@ const RowOptionsMenu = ({
deleteClickHandler, deleteClickHandler,
approveClickHandler, approveClickHandler,
rejectClickHandler, rejectClickHandler,
isGradingCompleted,
}: { }: {
type: 'dropdown' | 'collapse'; type: 'dropdown' | 'collapse';
props: CellContext<Recording, unknown>; props: CellContext<Recording, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
approveClickHandler: () => void; approveClickHandler: () => void;
rejectClickHandler: () => void; rejectClickHandler: () => void;
isGradingCompleted: (recording: Recording) => boolean;
}) => { }) => {
const isLayingCategory =
props.row.original.project_flock_category === 'LAYING';
const isRecordingApproved = (recording: Recording) => { const isRecordingApproved = (recording: Recording) => {
return ( return (
recording.approval?.action === 'APPROVED' && recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui' && recording.approval?.step_number === 2 &&
recording.approval?.step_number === 3 recording.approval?.step_name === 'Disetujui'
); );
}; };
const isRecordingRejected = (recording: Recording) => {
return recording.approval?.action === 'REJECTED';
};
const isApproved = isRecordingApproved(props.row.original); const isApproved = isRecordingApproved(props.row.original);
const isGradingDone = isGradingCompleted(props.row.original); const isRejected = isRecordingRejected(props.row.original);
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<Button <Button
href={`recording/detail/?recordingId=${props.row.original.id}`} href={`/production/recording/detail/?recordingId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='primary' color='primary'
className='justify-start text-sm' className='justify-start text-sm'
@@ -70,7 +69,7 @@ const RowOptionsMenu = ({
Detail Detail
</Button> </Button>
<Button <Button
href={`recording/detail/edit/?recordingId=${props.row.original.id}`} href={`/production/recording/detail/edit/?recordingId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='warning'
className='justify-start text-sm' className='justify-start text-sm'
@@ -78,7 +77,7 @@ const RowOptionsMenu = ({
<Icon icon='mdi:pencil-outline' width={16} height={16} /> <Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit Edit
</Button> </Button>
{!isApproved && !(isLayingCategory && !isGradingDone) && ( {!isApproved && !isRejected && (
<Button <Button
onClick={approveClickHandler} onClick={approveClickHandler}
variant='ghost' variant='ghost'
@@ -89,7 +88,7 @@ const RowOptionsMenu = ({
Approve Approve
</Button> </Button>
)} )}
{!isApproved && !(isLayingCategory && !isGradingDone) && ( {!isApproved && !isRejected && (
<Button <Button
onClick={rejectClickHandler} onClick={rejectClickHandler}
variant='ghost' variant='ghost'
@@ -370,7 +369,7 @@ const RecordingTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
const [approvalNotes, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const singleDeleteModal = useModal(); const singleDeleteModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
@@ -386,33 +385,10 @@ const RecordingTable = () => {
RecordingApi.getAllFetcher RecordingApi.getAllFetcher
); );
const isRecordingFullyApproved = useCallback( const isRecordingApproved = useCallback((recording: Recording): boolean => {
(recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui' &&
Number(recording.approval?.step_number) === 3
);
},
[]
);
const isRecordingApproved = useCallback(
(recording: Recording) => {
return isRecordingFullyApproved(recording);
},
[isRecordingFullyApproved]
);
const isGradingCompleted = useCallback((recording: Recording): boolean => {
if (recording.project_flock_category !== 'LAYING') {
return true;
}
return ( return (
recording.egg_grading_status === 'COMPLETED' || recording.approval?.action === 'APPROVED' &&
(recording.approval?.action === 'UPDATED' && recording.approval?.step_name === 'Disetujui'
recording.approval?.step_number === 2)
); );
}, []); }, []);
@@ -506,19 +482,9 @@ const RecordingTable = () => {
if (!isResponseSuccess(recordings) || !recordings.data) return []; if (!isResponseSuccess(recordings) || !recordings.data) return [];
return selectedRowIds.filter((id) => { return selectedRowIds.filter((id) => {
const recording = recordings.data.find((r) => r.id === id); const recording = recordings.data.find((r) => r.id === id);
if (!recording || isRecordingApproved(recording)) return false; return recording && !isRecordingApproved(recording);
if (recording.project_flock_category === 'GROWING') {
return true;
}
if (recording.project_flock_category === 'LAYING') {
return isGradingCompleted(recording);
}
return false;
}); });
}, [selectedRowIds, recordings, isRecordingApproved, isGradingCompleted]); }, [selectedRowIds, recordings, isRecordingApproved]);
useEffect(() => { useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) { if (isResponseSuccess(recordings) && recordings.data) {
@@ -530,14 +496,7 @@ const RecordingTable = () => {
(r) => r.id === parseInt(rowId) (r) => r.id === parseInt(rowId)
); );
if (recording && !isRecordingApproved(recording)) { if (recording && !isRecordingApproved(recording)) {
if (recording.project_flock_category === 'GROWING') { newSelection[rowId] = true;
newSelection[rowId] = true;
} else if (
recording.project_flock_category === 'LAYING' &&
isGradingCompleted(recording)
) {
newSelection[rowId] = true;
}
} }
} }
}); });
@@ -548,13 +507,7 @@ const RecordingTable = () => {
setRowSelection(newSelection); setRowSelection(newSelection);
} }
} }
}, [ }, [recordings, rowSelection, isRecordingApproved, setRowSelection]);
recordings,
rowSelection,
isRecordingApproved,
isGradingCompleted,
setRowSelection,
]);
return ( return (
<div className='w-full p-0 sm:p-4'> <div className='w-full p-0 sm:p-4'>
@@ -562,7 +515,7 @@ const RecordingTable = () => {
<div className='w-full flex flex-col xl:flex-row justify-between items-end xl:items-center gap-2'> <div className='w-full flex flex-col xl:flex-row justify-between items-end xl:items-center gap-2'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'> <div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
<Button <Button
href='recording/add' href='/production/recording/add'
variant='outline' variant='outline'
color='primary' color='primary'
className='w-full sm:w-fit' className='w-full sm:w-fit'
@@ -640,40 +593,28 @@ const RecordingTable = () => {
id: 'select', id: 'select',
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter((row) => {
const selectableGrowingRows = allRows.filter((row) => {
const recording = row.original; const recording = row.original;
return ( return !isRecordingApproved(recording);
recording.project_flock_category === 'GROWING' &&
!isRecordingApproved(recording)
);
}); });
const hasNoSelectableGrowing = selectableGrowingRows.length === 0; const hasNoSelectableRows = selectableRows.length === 0;
const handleSelectAllGrowing = () => { const handleSelectAll = () => {
const isAllSelected = selectableGrowingRows.every((row) => const isAllSelected = selectableRows.every((row) =>
row.getIsSelected() row.getIsSelected()
); );
allRows.forEach((row) => { selectableRows.forEach((row) => {
const recording = row.original; row.toggleSelected(!isAllSelected);
if (
recording.project_flock_category === 'GROWING' &&
!isRecordingApproved(recording)
) {
row.toggleSelected(!isAllSelected);
} else if (recording.project_flock_category === 'LAYING') {
row.toggleSelected(false);
}
}); });
}; };
const isAllGrowingSelected = const isAllSelected =
selectableGrowingRows.length > 0 && selectableRows.length > 0 &&
selectableGrowingRows.every((row) => row.getIsSelected()); selectableRows.every((row) => row.getIsSelected());
const isSomeGrowingSelected = selectableGrowingRows.some((row) => const isSomeSelected = selectableRows.some((row) =>
row.getIsSelected() row.getIsSelected()
); );
@@ -681,33 +622,20 @@ const RecordingTable = () => {
<div className='w-full flex flex-row justify-center'> <div className='w-full flex flex-row justify-center'>
<CheckboxInput <CheckboxInput
name='allRow' name='allRow'
checked={isAllGrowingSelected} checked={isAllSelected}
indeterminate={ indeterminate={isSomeSelected && !isAllSelected}
isSomeGrowingSelected && !isAllGrowingSelected onChange={handleSelectAll}
} disabled={hasNoSelectableRows}
onChange={handleSelectAllGrowing}
disabled={hasNoSelectableGrowing}
/> />
</div> </div>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const isApproved = isRecordingApproved(row.original);
const isLayingCategory =
row.original.project_flock_category === 'LAYING';
if (isLayingCategory) {
return null;
}
const isDisabled = !row.getCanSelect() || isApproved;
return ( return (
<div> <div>
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={row.getIsSelected()} checked={row.getIsSelected()}
disabled={isDisabled}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
/> />
@@ -883,7 +811,6 @@ const RecordingTable = () => {
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
approveClickHandler={approveClickHandler} approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler} rejectClickHandler={rejectClickHandler}
isGradingCompleted={isGradingCompleted}
/> />
</RowDropdownOptions> </RowDropdownOptions>
)} )}
@@ -896,7 +823,6 @@ const RecordingTable = () => {
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
approveClickHandler={approveClickHandler} approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler} rejectClickHandler={rejectClickHandler}
isGradingCompleted={isGradingCompleted}
/> />
</RowCollapseOptions> </RowCollapseOptions>
)} )}
@@ -4,7 +4,6 @@ import {
CreateGrowingRecordingPayload, CreateGrowingRecordingPayload,
CreateLayingRecordingPayload, CreateLayingRecordingPayload,
CreateEggPayload, CreateEggPayload,
CreateGradingPayload,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
type RecordingGrowingFormSchemaType = { type RecordingGrowingFormSchemaType = {
@@ -32,14 +31,7 @@ type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: { eggs: {
product_warehouse_id: number; product_warehouse_id: number;
qty: number | string; qty: number | string;
}[]; weight: number | string;
};
type RecordingGradingFormSchemaType = {
eggs_grading: {
recording_egg_id: number;
grade: string;
qty: number | string;
}[]; }[];
}; };
@@ -62,6 +54,7 @@ export type DepletionSchema = {
export type EggSchema = { export type EggSchema = {
product_warehouse_id: number; product_warehouse_id: number;
qty: number | string; qty: number | string;
weight: number | string;
}; };
const BodyWeightObjectSchema: Yup.ObjectSchema<BodyWeightSchema> = Yup.object({ const BodyWeightObjectSchema: Yup.ObjectSchema<BodyWeightSchema> = Yup.object({
@@ -109,6 +102,10 @@ const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
.required('Jumlah telur wajib diisi!') .required('Jumlah telur wajib diisi!')
.min(1, 'Jumlah telur tidak boleh 0!') .min(1, 'Jumlah telur tidak boleh 0!')
.typeError('Jumlah telur harus berupa angka!'), .typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number()
.required('Berat telur wajib diisi!')
.min(1, 'Berat telur minimal 1 gram!')
.typeError('Berat telur harus berupa angka!'),
}); });
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> = export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
@@ -190,30 +187,6 @@ export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({
.required('Project Flock Kandang wajib diisi!'), .required('Project Flock Kandang wajib diisi!'),
}); });
export const RecordingGradingFormSchema: Yup.ObjectSchema<RecordingGradingFormSchemaType> =
Yup.object({
eggs_grading: Yup.array()
.of(
Yup.object({
recording_egg_id: Yup.number()
.required('Recording Egg ID wajib diisi!')
.min(1, 'Recording Egg ID minimal 1!')
.typeError('Recording Egg ID harus berupa angka!'),
grade: Yup.string()
.required('Grade telur wajib diisi!')
.typeError('Grade telur harus berupa string!'),
qty: Yup.number()
.required('Jumlah telur wajib diisi!')
.min(1, 'Jumlah telur minimal 1!')
.typeError('Jumlah telur harus berupa angka!'),
})
)
.min(1, 'Minimal harus ada 1 data grading telur!')
.required('Data grading telur wajib diisi!'),
});
export const UpdateRecordingGradingFormSchema = RecordingGradingFormSchema;
export type RecordingGrowingFormValues = Yup.InferType< export type RecordingGrowingFormValues = Yup.InferType<
typeof RecordingGrowingFormSchema typeof RecordingGrowingFormSchema
>; >;
@@ -222,10 +195,6 @@ export type RecordingLayingFormValues = Yup.InferType<
typeof RecordingLayingFormSchema typeof RecordingLayingFormSchema
>; >;
export type RecordingGradingFormValues = Yup.InferType<
typeof RecordingGradingFormSchema
>;
type RecordingFormData = Partial<Recording> & { type RecordingFormData = Partial<Recording> & {
body_weights?: CreateGrowingRecordingPayload['body_weights']; body_weights?: CreateGrowingRecordingPayload['body_weights'];
stocks?: CreateGrowingRecordingPayload['stocks'] | Recording['stocks']; stocks?: CreateGrowingRecordingPayload['stocks'] | Recording['stocks'];
@@ -295,26 +264,12 @@ export const getRecordingLayingFormInitialValues = (
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({ eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: egg.product_warehouse_id, product_warehouse_id: egg.product_warehouse_id,
qty: egg.qty, qty: egg.qty,
weight: egg.weight,
})) ?? [ })) ?? [
{ {
product_warehouse_id: 0, product_warehouse_id: 0,
qty: '', qty: '',
}, weight: '',
],
});
export const getRecordingGradingFormInitialValues = (
initialValues?: Partial<CreateGradingPayload> & { recording_egg_id?: number }
): RecordingGradingFormValues => ({
eggs_grading: initialValues?.eggs_grading?.map((grading) => ({
recording_egg_id: grading.recording_egg_id,
grade: grading.grade,
qty: grading.qty,
})) ?? [
{
recording_egg_id: initialValues?.recording_egg_id ?? 0,
grade: '',
qty: '',
}, },
], ],
}); });
@@ -16,7 +16,6 @@ import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import Tooltip from '@/components/Tooltip';
import { import {
ProjectFlockKandangApi, ProjectFlockKandangApi,
@@ -48,7 +47,7 @@ import {
getRecordingLayingFormInitialValues, getRecordingLayingFormInitialValues,
UpdateRecordingGrowingFormSchema, UpdateRecordingGrowingFormSchema,
UpdateRecordingLayingFormSchema, UpdateRecordingLayingFormSchema,
} from './RecordingForm.schema'; } from '@/components/pages/production/recording/form/RecordingForm.schema';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
@@ -98,9 +97,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const [recordingFormErrorMessage, setRecordingFormErrorMessage] = const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
useState(''); useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [newRecordingData, setNewRecordingData] = useState<Recording | null>( const [, setNewRecordingData] = useState<Recording | null>(null);
null
);
const [nextDayRecording, setNextDayRecording] = const [nextDayRecording, setNextDayRecording] =
useState<NextDayRecording | null>(null); useState<NextDayRecording | null>(null);
@@ -111,19 +108,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const isRecordingApproved = useCallback((recording?: Recording) => { const isRecordingApproved = useCallback((recording?: Recording) => {
return ( return (
recording?.approval?.action === 'APPROVED' && recording?.approval?.action === 'APPROVED' &&
recording?.approval?.step_name === 'Disetujui' && recording?.approval?.step_name === 'Disetujui'
recording?.approval?.step_number === 3
); );
}, []); }, []);
const hasGradingData = useCallback((recording?: Recording) => { const isRecordingRejected = useCallback((recording?: Recording) => {
if (!recording || !recording.eggs) return false; return recording?.approval?.action === 'REJECTED';
return recording.eggs.some(
(egg) =>
egg.gradings &&
egg.gradings.length > 0 &&
egg.gradings.some((grading) => grading.qty > 0)
);
}, []); }, []);
// ===== PAYLOAD CREATION HELPERS ===== // ===== PAYLOAD CREATION HELPERS =====
@@ -181,6 +171,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
eggs: (values.eggs ?? []).map((egg) => ({ eggs: (values.eggs ?? []).map((egg) => ({
product_warehouse_id: egg.product_warehouse_id, product_warehouse_id: egg.product_warehouse_id,
qty: Number(egg.qty) || 0, qty: Number(egg.qty) || 0,
weight:
typeof egg.weight === 'number'
? egg.weight
: parseFloat(String(egg.weight)) || 0,
})), })),
}; };
}, },
@@ -203,35 +197,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
[router] [router]
); );
const createRecordingHandlerWithRedirect = useCallback(
async (
payload: CreateGrowingRecordingPayload | CreateLayingRecordingPayload,
redirectToGrading: boolean = false
) => {
const res = await RecordingApi.create(payload);
if (isResponseError(res)) {
setRecordingFormErrorMessage(res.message);
return null;
}
toast.success(res?.message as string);
if (res?.status === 'success' && res.data) {
setNewRecordingData(res.data);
return res.data;
}
if (redirectToGrading) {
toast.error(
'Gagal mendapatkan ID recording. Silakan coba dari halaman list.'
);
router.push('/production/recording');
}
return null;
},
[router]
);
const updateRecordingHandler = useCallback( const updateRecordingHandler = useCallback(
async ( async (
recordingId: number, recordingId: number,
@@ -650,7 +615,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const hasPakanFlag = product.product.flags?.includes('PAKAN'); const hasPakanFlag = product.product.flags?.includes('PAKAN');
const hasOvkFlag = product.product.flags?.includes('OVK'); const hasOvkFlag = product.product.flags?.includes('OVK');
// Only include products that are in the same location as the selected kandang
if (hasPakanFlag || hasOvkFlag) { if (hasPakanFlag || hasOvkFlag) {
options.push({ options.push({
value: product.id, value: product.id,
@@ -690,7 +654,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
depletionProductsData.data.forEach((product) => { depletionProductsData.data.forEach((product) => {
const productName = product.product.name; const productName = product.product.name;
// Filter for depletion-related products (culling, mati, afkir)
if ( if (
productName.toLowerCase().includes('culling') || productName.toLowerCase().includes('culling') ||
productName.toLowerCase().includes('mati') || productName.toLowerCase().includes('mati') ||
@@ -732,7 +695,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
eggProductsData.data.forEach((product) => { eggProductsData.data.forEach((product) => {
const productName = product.product.name; const productName = product.product.name;
// Filter for egg-related products
if ( if (
productName.toLowerCase().includes('telur') || productName.toLowerCase().includes('telur') ||
productName.toLowerCase().includes('egg') || productName.toLowerCase().includes('egg') ||
@@ -1019,54 +981,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
); );
}, [formik.values.stocks, getStockUsageError, type]); }, [formik.values.stocks, getStockUsageError, type]);
const hasConsumableEggs = useMemo(() => {
if (!isLayingCategory) return false;
const layingValues = formik.values as RecordingLayingFormValues;
if (!layingValues.eggs || layingValues.eggs.length === 0) return false;
return layingValues.eggs.some((egg) => {
if (!egg.product_warehouse_id || Number(egg.qty) <= 0) return false;
const product = eggProducts.find(
(opt) => opt.value === egg.product_warehouse_id
);
if (!product) return false;
const productName = product.label.toLowerCase();
return (
productName.includes('konsumsi') &&
productName.includes('baik') &&
Number(egg.qty) > 0
);
});
}, [isLayingCategory, formik.values, eggProducts]);
const hasConsumableEggsInRecording = useCallback((recording?: Recording) => {
if (!recording || !recording.eggs || recording.eggs.length === 0)
return false;
return recording.eggs.some((egg) => {
if (!egg.product_warehouse || !egg.product_warehouse.product)
return false;
if (Number(egg.qty) <= 0) return false;
const productName = egg.product_warehouse.product.name.toLowerCase();
return (
productName.includes('konsumsi') &&
productName.includes('baik') &&
Number(egg.qty) > 0
);
});
}, []);
const hasConsumableEggsInCurrentRecording = useMemo(() => {
return (
hasConsumableEggsInRecording(initialValues) ||
hasConsumableEggsInRecording(newRecordingData || undefined)
);
}, [initialValues, newRecordingData, hasConsumableEggsInRecording]);
const isRepeaterInputError = ( const isRepeaterInputError = (
arrayName: 'body_weights' | 'stocks' | 'depletions' | 'eggs', arrayName: 'body_weights' | 'stocks' | 'depletions' | 'eggs',
column: string, column: string,
@@ -1148,7 +1062,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (hasSameDayRecording) { if (hasSameDayRecording) {
toast.error( toast.error(
`Recording untuk hari ${nextDayRecording.next_day} sudah ada. `Recording untuk hari ${nextDayRecording.next_day} sudah ada.
Tidak bisa membuat recording duplikat, mohon perbarui recording yang sudah ada terlebih dahulu.` Tidak bisa membuat recording duplikat, mohon perbarui recording yang sudah ada terlebih dahulu.`
); );
return; return;
@@ -1278,7 +1192,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setIsRejectLoading(false); setIsRejectLoading(false);
}; };
// Body Weights Handlers
const addBodyWeight = () => { const addBodyWeight = () => {
const newBodyWeights = [ const newBodyWeights = [
...(formik.values.body_weights || []), ...(formik.values.body_weights || []),
@@ -1397,7 +1310,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedBodyWeights([]); setSelectedBodyWeights([]);
}; };
// Stocks Handlers
const addStock = () => { const addStock = () => {
const newStocks = [ const newStocks = [
...(formik.values.stocks || []), ...(formik.values.stocks || []),
@@ -1430,7 +1342,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedStocks([]); setSelectedStocks([]);
}; };
// Depletions Handlers
const addDepletion = () => { const addDepletion = () => {
const newDepletions = [ const newDepletions = [
...(formik.values.depletions || []), ...(formik.values.depletions || []),
@@ -1465,7 +1376,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedDepletions([]); setSelectedDepletions([]);
}; };
// Eggs Handlers
const addEgg = () => { const addEgg = () => {
const newEggs = [ const newEggs = [
...((formik.values as RecordingLayingFormValues).eggs || []), ...((formik.values as RecordingLayingFormValues).eggs || []),
@@ -1485,6 +1395,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
[formik] [formik]
); );
const handleEggWeightChangeWrapper = useCallback(
(idx: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value) || 0;
formik.setFieldValue(`eggs.${idx}.weight`, value);
},
[formik]
);
const removeEgg = (idx: number) => { const removeEgg = (idx: number) => {
const updatedEggs = ( const updatedEggs = (
formik.values as RecordingLayingFormValues formik.values as RecordingLayingFormValues
@@ -1570,8 +1488,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</Button> </Button>
{type === 'detail' && {type === 'detail' &&
initialValues?.approval &&
!isRecordingApproved(initialValues) && !isRecordingApproved(initialValues) &&
(!isLayingCategory || hasGradingData(initialValues)) && ( !isRecordingRejected(initialValues) && (
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Button <Button
variant='outline' variant='outline'
@@ -1916,7 +1835,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{formik.values.body_weights?.map((bw, idx) => ( {formik.values.body_weights?.map((bw, idx) => (
<tr key={`body-weight-${idx}`}> <tr key={`body-weight-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`body-weight-${idx}`} name={`body-weight-${idx}`}
checked={selectedBodyWeights.includes(idx)} checked={selectedBodyWeights.includes(idx)}
@@ -2166,7 +2085,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{formik.values.stocks?.map((stock, idx) => ( {formik.values.stocks?.map((stock, idx) => (
<tr key={`stock-${idx}`}> <tr key={`stock-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`stock-${idx}`} name={`stock-${idx}`}
checked={selectedStocks.includes(idx)} checked={selectedStocks.includes(idx)}
@@ -2386,7 +2305,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{formik.values.depletions?.map((depletion, idx) => ( {formik.values.depletions?.map((depletion, idx) => (
<tr key={`depletion-${idx}`}> <tr key={`depletion-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`depletion-${idx}`} name={`depletion-${idx}`}
checked={selectedDepletions.includes(idx)} checked={selectedDepletions.includes(idx)}
@@ -2587,6 +2506,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
</th> </th>
<th>
Berat (gram)
<span
className='tooltip tooltip-error tooltip-bottom '
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>Action</th> <th>Action</th>
)} )}
@@ -2597,7 +2525,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(egg, idx) => ( (egg, idx) => (
<tr key={`egg-${idx}`}> <tr key={`egg-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`egg-${idx}`} name={`egg-${idx}`}
checked={selectedEggs.includes(idx)} checked={selectedEggs.includes(idx)}
@@ -2662,32 +2590,55 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
/> />
</td> </td>
<td> <td>
<div className='flex flex-col gap-1'> <NumberInput
<NumberInput required
required name={`eggs.${idx}.qty`}
name={`eggs.${idx}.qty`} value={egg.qty ?? ''}
value={egg.qty ?? ''} onChange={handleEggQtyChangeWrapper(idx)}
onChange={handleEggQtyChangeWrapper(idx)} onBlur={formik.handleBlur}
onBlur={formik.handleBlur} decimalScale={0}
decimalScale={0} allowNegative={false}
allowNegative={false} thousandSeparator=','
thousandSeparator=',' decimalSeparator='.'
decimalSeparator='.' isError={
isError={ isRepeaterInputError('eggs', 'qty', idx).isError
isRepeaterInputError('eggs', 'qty', idx) }
.isError errorMessage={
} isRepeaterInputError('eggs', 'qty', idx)
errorMessage={ .errorMessage
isRepeaterInputError('eggs', 'qty', idx) }
.errorMessage readOnly={type === 'detail'}
} className={{
readOnly={type === 'detail'} wrapper: 'w-full min-w-24',
className={{ }}
wrapper: 'w-full min-w-24', placeholder='Masukkan jumlah telur'
}} />
placeholder='Masukkan jumlah telur' </td>
/> <td>
</div> <NumberInput
required
name={`eggs.${idx}.weight`}
value={egg.weight ?? ''}
onChange={handleEggWeightChangeWrapper(idx)}
onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
isError={
isRepeaterInputError('eggs', 'weight', idx)
.isError
}
errorMessage={
isRepeaterInputError('eggs', 'weight', idx)
.errorMessage
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
placeholder='Masukkan berat telur (gram)...'
/>
</td> </td>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td> <td>
@@ -2779,46 +2730,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div> </div>
{/* Right side actions */} {/* Right side actions */}
<div className='flex flex-col sm:flex-row sm:justify-end gap-2 w-full sm:w-auto'> <div className='flex flex-col sm:flex-row sm:justify-end gap-2 w-full sm:w-auto'>
{type === 'detail' && isLayingCategory && (
<Tooltip
content={
hasConsumableEggsInCurrentRecording
? 'Lanjut ke proses grading untuk telur konsumsi baik'
: 'Hanya bisa melanjutkan ke grading jika ada Telur Konsumsi Baik'
}
position='left'
color={
hasConsumableEggsInCurrentRecording ? 'info' : 'warning'
}
>
<Button
type='button'
color='primary'
disabled={!hasConsumableEggsInCurrentRecording}
className='w-full sm:w-auto'
onClick={() => {
const recordingId =
newRecordingData?.id || initialValues?.id;
if (recordingId) {
router.push(
`/production/recording/grading/add?recording_id=${recordingId}`
);
} else {
toast.error(
'Recording ID tidak ditemukan. Silakan refresh halaman.'
);
}
}}
>
<Icon icon='material-symbols:egg' width={24} height={24} />
{hasGradingData(initialValues) ||
hasGradingData(newRecordingData || undefined)
? 'Edit Grading'
: 'Lanjut ke Grading'}
</Button>
</Tooltip>
)}
{type === 'edit' && ( {type === 'edit' && (
<div className='flex flex-col sm:flex-row gap-2 w-full sm:w-auto'> <div className='flex flex-col sm:flex-row gap-2 w-full sm:w-auto'>
<Button <Button
@@ -2870,79 +2781,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
> >
Submit Submit
</Button> </Button>
{isLayingCategory && (
<Tooltip
content={
hasConsumableEggs
? 'Lanjut ke proses grading untuk telur konsumsi baik'
: 'Hanya bisa melanjutkan ke grading jika ada Telur Konsumsi Baik'
}
position='left'
color={hasConsumableEggs ? 'info' : 'warning'}
>
<Button
type='button'
color='info'
className='px-4'
isLoading={formik.isSubmitting}
disabled={
hasExceededStock ||
!formik.isValid ||
formik.isSubmitting ||
!hasConsumableEggs
}
onClick={async () => {
if (!formik.isValid) {
await formik.validateForm();
return;
}
setRecordingFormErrorMessage('');
formik.setSubmitting(true);
try {
if (isLayingCategory) {
const layingValues =
formik.values as RecordingLayingFormValues;
const layingPayload =
createLayingPayload(layingValues);
const recordingData =
await createRecordingHandlerWithRedirect(
layingPayload as CreateLayingRecordingPayload,
true
);
if (recordingData?.id) {
toast.success(
'Recording berhasil disimpan! Mengalihkan ke form Grading...'
);
setTimeout(() => {
router.push(
`/production/recording/grading/add?recording_id=${recordingData.id}`
);
}, 1000);
}
}
} catch (error) {
console.error('Error creating recording:', error);
toast.error(
'Gagal membuat recording. Silakan coba lagi.'
);
} finally {
formik.setSubmitting(false);
}
}}
>
<Icon
icon='material-symbols:egg'
width={24}
height={24}
/>
Next Step: Grading
</Button>
</Tooltip>
)}
</div> </div>
)} )}
</div> </div>
@@ -2981,7 +2819,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{/* Approve Confirmation Modal */} {/* Approve Confirmation Modal */}
{(type as 'add' | 'edit' | 'detail') === 'detail' && {(type as 'add' | 'edit' | 'detail') === 'detail' &&
!isRecordingApproved(initialValues) && !isRecordingApproved(initialValues) &&
(!isLayingCategory || hasGradingData(initialValues)) && ( !isRecordingRejected(initialValues) && (
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={approveModal.ref} ref={approveModal.ref}
type='success' type='success'
@@ -3003,8 +2841,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{/* Reject Confirmation Modal */} {/* Reject Confirmation Modal */}
{(type as 'add' | 'edit' | 'detail') === 'detail' && {(type as 'add' | 'edit' | 'detail') === 'detail' &&
!isRecordingApproved(initialValues) && !isRecordingApproved(initialValues) && (
(!isLayingCategory || hasGradingData(initialValues)) && (
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={rejectModal.ref} ref={rejectModal.ref}
type='error' type='error'
File diff suppressed because it is too large Load Diff
@@ -226,9 +226,7 @@ export const getFilledTransferToLayingFormInitialValues = async (
// targetKandang.target_project_flock_kandang.kandang.capacity, // targetKandang.target_project_flock_kandang.kandang.capacity,
// TODO: integrate this to real API kandang capacity // TODO: integrate this to real API kandang capacity
maxQuantity: maxQuantity: Infinity,
targetKandang.target_project_flock_kandang.kandang.capacity ??
Infinity,
})) }))
: [], : [],
@@ -16,7 +16,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { cn, formatDate, formatCurrency } from '@/lib/helper'; import { cn, 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';
@@ -136,14 +136,6 @@ const PurchaseTable = () => {
? formatDate(props.row.original.po_date, 'DD MMM YYYY') ? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-', : '-',
}, },
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
cell: (props) =>
props.row.original.due_date
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
: '-',
},
{ {
header: 'Aging', header: 'Aging',
cell: (props) => { cell: (props) => {
@@ -156,11 +148,6 @@ const PurchaseTable = () => {
return `${diffDays} hari`; return `${diffDays} hari`;
}, },
}, },
{
accessorKey: 'grand_total',
header: 'Total (Rp.)',
cell: (props) => formatCurrency(props.row.original.grand_total),
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props) => { cell: (props) => {
@@ -18,7 +18,7 @@ import {
PurchaseRequestAcceptApprovalFormDefaultValues, PurchaseRequestAcceptApprovalFormDefaultValues,
PurchaseRequestAcceptApprovalFormInitialValues, PurchaseRequestAcceptApprovalFormInitialValues,
PurchaseRequestAcceptApprovalFormSchema, PurchaseRequestAcceptApprovalFormSchema,
} from './PurchaseOrderForm.schema'; } from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { PurchaseApi } from '@/services/api/purchase'; import { PurchaseApi } from '@/services/api/purchase';
import { import {
@@ -52,6 +52,8 @@ const PurchaseOrderAcceptApprovalForm = ({
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState(''); useState('');
const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
// ===== UTILITY FUNCTIONS ===== // ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = ( const isRepeaterInputError = (
idx: number, idx: number,
@@ -64,7 +66,6 @@ const PurchaseOrderAcceptApprovalForm = ({
| 'expedition_vendor_id' | 'expedition_vendor_id'
| 'received_qty' | 'received_qty'
| 'transport_per_item' | 'transport_per_item'
| 'transport_total'
): { isError: boolean; errorMessage: string } => { ): { isError: boolean; errorMessage: string } => {
const touchedItem = formik.touched.items?.[idx]; const touchedItem = formik.touched.items?.[idx];
const errorItem = formik.errors.items?.[idx] as const errorItem = formik.errors.items?.[idx] as
@@ -163,6 +164,7 @@ const PurchaseOrderAcceptApprovalForm = ({
validateOnBlur: true, validateOnBlur: true,
onSubmit: async (values) => { onSubmit: async (values) => {
const payload: CreateAcceptApprovalRequestPayload = { const payload: CreateAcceptApprovalRequestPayload = {
action: 'APPROVED',
notes: values.notes || '', notes: values.notes || '',
items: items:
values.items?.map((formItem) => { values.items?.map((formItem) => {
@@ -181,10 +183,6 @@ const PurchaseOrderAcceptApprovalForm = ({
typeof formItem.transport_per_item === 'string' typeof formItem.transport_per_item === 'string'
? parseFloat(formItem.transport_per_item) || 0 ? parseFloat(formItem.transport_per_item) || 0
: formItem.transport_per_item || 0, : formItem.transport_per_item || 0,
transport_total:
typeof formItem.transport_total === 'string'
? parseFloat(formItem.transport_total) || 0
: formItem.transport_total || 0,
}; };
}) || [], }) || [],
}; };
@@ -239,9 +237,8 @@ const PurchaseOrderAcceptApprovalForm = ({
vehicle_number: item.vehicle_number || '', vehicle_number: item.vehicle_number || '',
expedition_vendor: null, expedition_vendor: null,
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: item.total_qty || '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
}; };
}); });
formik.setFieldValue('items', updatedItems); formik.setFieldValue('items', updatedItems);
@@ -301,7 +298,7 @@ const PurchaseOrderAcceptApprovalForm = ({
// ===== PURCHASE ITEM OPERATIONS ===== // ===== PURCHASE ITEM OPERATIONS =====
const handlePurchaseItemChange = ( const handlePurchaseItemChange = (
idx: number, idx: number,
field: 'received_qty' | 'transport_per_item' | 'transport_total', field: 'received_qty' | 'transport_per_item',
value: string | number value: string | number
) => { ) => {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
@@ -318,26 +315,6 @@ const PurchaseOrderAcceptApprovalForm = ({
: parseFloat( : parseFloat(
formik.values.items?.[idx]?.transport_per_item as string formik.values.items?.[idx]?.transport_per_item as string
) || 0; ) || 0;
if (receivedQty > 0 && transportPerItem >= 0) {
const calculatedTransportTotal = receivedQty * transportPerItem;
formik.setFieldValue(
`items.${idx}.transport_total`,
calculatedTransportTotal
);
}
}
if (field === 'transport_total') {
const receivedQty =
parseFloat(formik.values.items?.[idx]?.received_qty as string) || 0;
if (receivedQty > 0 && numValue >= 0) {
const calculatedTransportPerItem = numValue / receivedQty;
formik.setFieldValue(
`items.${idx}.transport_per_item`,
calculatedTransportPerItem
);
}
} }
}; };
@@ -386,10 +363,6 @@ const PurchaseOrderAcceptApprovalForm = ({
Transport/Item Transport/Item
<span className='text-error'>*</span> <span className='text-error'>*</span>
</th> </th>
<th>
Total Transport
<span className='text-error'>*</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -657,37 +630,6 @@ const PurchaseOrderAcceptApprovalForm = ({
}} }}
/> />
</td> </td>
<td>
<NumberInput
required
name={`items.${idx}.transport_total`}
value={formItem?.transport_total || ''}
onChange={(e) =>
handlePurchaseItemChange(
idx,
'transport_total',
e.target.value
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan total transport'
allowNegative={false}
decimalScale={2}
thousandSeparator=','
decimalSeparator='.'
inputPrefix={'Rp'}
isError={
isRepeaterInputError(idx, 'transport_total').isError
}
errorMessage={
isRepeaterInputError(idx, 'transport_total')
.errorMessage
}
className={{
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
}}
/>
</td>
</tr> </tr>
); );
})} })}
@@ -732,7 +674,8 @@ const PurchaseOrderAcceptApprovalForm = ({
disabled={ disabled={
!formik.isValid || !formik.isValid ||
formik.isSubmitting || formik.isSubmitting ||
hasQuantityExceededErrors hasQuantityExceededErrors ||
isRejected
} }
> >
Submit Submit
@@ -23,10 +23,12 @@ type PurchaseRequestStaffApprovalFormSchemaType = {
}; };
type PurchaseRequestManagerApprovalFormSchemaType = { type PurchaseRequestManagerApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null; notes: string | null;
}; };
type PurchaseRequestAcceptApprovalFormSchemaType = { type PurchaseRequestAcceptApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null; notes: string | null;
items: { items: {
purchase_item?: { purchase_item?: {
@@ -45,7 +47,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
expedition_vendor_id: number; expedition_vendor_id: number;
received_qty: number | string; received_qty: number | string;
transport_per_item: number | string; transport_per_item: number | string;
transport_total: number | string;
}[]; }[];
}; };
@@ -83,7 +84,6 @@ export type PurchaseAcceptApprovalItemSchema = {
expedition_vendor_id: number; expedition_vendor_id: number;
received_qty: number | string; received_qty: number | string;
transport_per_item: number | string; transport_per_item: number | string;
transport_total: number | string;
}; };
export type PurchaseDeleteItemsSchema = { export type PurchaseDeleteItemsSchema = {
@@ -152,6 +152,10 @@ const PurchaseStaffApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseStaffAppro
const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> = const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> =
Yup.object({ Yup.object({
action: Yup.mixed<'APPROVED' | 'REJECTED'>()
.oneOf(['APPROVED', 'REJECTED'], 'Action harus APPROVED atau REJECTED')
.required('Action wajib diisi!')
.default('APPROVED'),
notes: Yup.string().nullable().default(null), notes: Yup.string().nullable().default(null),
}); });
@@ -230,20 +234,6 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
} }
) )
.typeError('Biaya transport per item harus berupa angka!'), .typeError('Biaya transport per item harus berupa angka!'),
transport_total: Yup.mixed<string | number>()
.required('Total biaya transport wajib diisi!')
.test(
'is-valid-transport-total',
'Total biaya transport harus berupa angka lebih dari atau sama dengan 0!',
function (value) {
if (value === '' || value === null || value === undefined)
return false;
const numValue =
typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0;
}
)
.typeError('Total biaya transport harus berupa angka!'),
}); });
export const PurchaseRequestStaffApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestStaffApprovalFormSchemaType> = export const PurchaseRequestStaffApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestStaffApprovalFormSchemaType> =
@@ -368,6 +358,7 @@ export const PurchaseRequestManagerApprovalFormDefaultValues = (
purchase?: Purchase purchase?: Purchase
): PurchaseRequestManagerApprovalFormSchemaType => { ): PurchaseRequestManagerApprovalFormSchemaType => {
return { return {
action: 'APPROVED',
notes: purchase?.notes ?? null, notes: purchase?.notes ?? null,
}; };
}; };
@@ -378,6 +369,10 @@ export type PurchaseRequestManagerApprovalFormValues = Yup.InferType<
export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestAcceptApprovalFormSchemaType> = export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestAcceptApprovalFormSchemaType> =
Yup.object({ Yup.object({
action: Yup.mixed<'APPROVED' | 'REJECTED'>()
.oneOf(['APPROVED', 'REJECTED'], 'Action harus APPROVED atau REJECTED')
.required('Action wajib diisi!')
.default('APPROVED'),
notes: Yup.string().nullable().default(null), notes: Yup.string().nullable().default(null),
items: Yup.array() items: Yup.array()
.of(PurchaseAcceptApprovalItemObjectSchema) .of(PurchaseAcceptApprovalItemObjectSchema)
@@ -388,6 +383,7 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType = export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
{ {
action: 'APPROVED',
notes: '', notes: '',
items: [ items: [
{ {
@@ -399,7 +395,6 @@ export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcce
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
}, },
], ],
}; };
@@ -408,6 +403,7 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
purchase?: Purchase purchase?: Purchase
): PurchaseRequestAcceptApprovalFormSchemaType => { ): PurchaseRequestAcceptApprovalFormSchemaType => {
return { return {
action: 'APPROVED',
notes: purchase?.notes ?? null, notes: purchase?.notes ?? null,
items: purchase?.items items: purchase?.items
? purchase.items.map((item) => ({ ? purchase.items.map((item) => ({
@@ -419,7 +415,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
})) }))
: [ : [
{ {
@@ -431,7 +426,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
}, },
], ],
}; };
@@ -21,7 +21,7 @@ import {
PurchaseRequestStaffApprovalFormInitialValues, PurchaseRequestStaffApprovalFormInitialValues,
PurchaseRequestStaffApprovalFormSchema, PurchaseRequestStaffApprovalFormSchema,
PurchaseStaffApprovalItemSchema, PurchaseStaffApprovalItemSchema,
} from './PurchaseOrderForm.schema'; } from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { PurchaseApi } from '@/services/api/purchase'; import { PurchaseApi } from '@/services/api/purchase';
@@ -61,7 +61,7 @@ const PurchaseOrderStaffApprovalForm = ({
return 'add'; return 'add';
} }
const currentStep = initialValues?.approval?.step_number || 1; const currentStep = initialValues?.latest_approval?.step_number || 1;
switch (currentStep) { switch (currentStep) {
case 1: case 1:
@@ -77,7 +77,9 @@ const PurchaseOrderStaffApprovalForm = ({
// Step 4+ (Penerimaan Barang dan selesai), tidak boleh edit kalau sudah disetujui // Step 4+ (Penerimaan Barang dan selesai), tidak boleh edit kalau sudah disetujui
return 'edit'; return 'edit';
} }
}, [rawDataApprovals, propType, initialValues?.approval?.step_number]); }, [rawDataApprovals, propType, initialValues?.latest_approval?.step_number]);
const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -93,16 +95,16 @@ const PurchaseOrderStaffApprovalForm = ({
// ===== UTILITY FUNCTIONS ===== // ===== UTILITY FUNCTIONS =====
const canUpdatePurchaseItems = useMemo(() => { const canUpdatePurchaseItems = useMemo(() => {
if (!initialValues?.approval) return false; if (!initialValues?.latest_approval) return false;
const currentStep = initialValues.approval.step_number; const currentStep = initialValues.latest_approval.step_number;
return currentStep >= 3; return currentStep >= 3;
}, [initialValues?.approval]); }, [initialValues?.latest_approval]);
const canShowDeleteAddButtons = useMemo(() => { const canShowDeleteAddButtons = useMemo(() => {
if (!initialValues?.approval) return false; if (!initialValues?.latest_approval) return false;
const currentStep = initialValues.approval.step_number; const currentStep = initialValues.latest_approval.step_number;
// Step 2 (Staff Purchase) dengan mode 'add' tidak boleh add/delete items // Step 2 (Staff Purchase) dengan mode 'add' tidak boleh add/delete items
// User hanya boleh input harga dan total harga untuk items yang sudah ada // User hanya boleh input harga dan total harga untuk items yang sudah ada
@@ -112,7 +114,7 @@ const PurchaseOrderStaffApprovalForm = ({
// Step 3 (Manager Purchase) boleh add/delete items // Step 3 (Manager Purchase) boleh add/delete items
return currentStep === 3; return currentStep === 3;
}, [initialValues?.approval, type]); }, [initialValues?.latest_approval, type]);
const isRepeaterInputError = ( const isRepeaterInputError = (
idx: number, idx: number,
@@ -241,9 +243,8 @@ const PurchaseOrderStaffApprovalForm = ({
); );
formik.setFieldValue('items', updatedPurchaseItems); formik.setFieldValue('items', updatedPurchaseItems);
} }
} catch (error) { } catch {
toast.error('Terjadi kesalahan saat menghapus item pembelian'); toast.error('Terjadi kesalahan saat menghapus item pembelian');
console.error('Delete item error:', error);
} }
}, [ }, [
initialValues?.id, initialValues?.id,
@@ -313,7 +314,9 @@ const PurchaseOrderStaffApprovalForm = ({
const isNewItemForm = const isNewItemForm =
!formItem.purchase_item_id || formItem.purchase_item_id === 0; !formItem.purchase_item_id || formItem.purchase_item_id === 0;
let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; let cleanPayload: NonNullable<
UpdateStaffApprovalRequestPayload['items']
>[0];
if (isNewItemForm) { if (isNewItemForm) {
cleanPayload = { cleanPayload = {
@@ -361,7 +364,9 @@ const PurchaseOrderStaffApprovalForm = ({
const isNewItemForm = const isNewItemForm =
!formItem.purchase_item_id || formItem.purchase_item_id === 0; !formItem.purchase_item_id || formItem.purchase_item_id === 0;
let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; let cleanPayload: NonNullable<
UpdateStaffApprovalRequestPayload['items']
>[0];
if (isNewItemForm) { if (isNewItemForm) {
cleanPayload = { cleanPayload = {
@@ -720,7 +725,10 @@ const PurchaseOrderStaffApprovalForm = ({
'min-w-52 md:min-w-72 lg:min-w-80', 'min-w-52 md:min-w-72 lg:min-w-80',
}} }}
bottomLabel={ bottomLabel={
'Previous: ' + purchaseItem.product.name type === 'edit'
? 'Previous: ' +
purchaseItem.product.name
: undefined
} }
/> />
</td> </td>
@@ -820,7 +828,11 @@ const PurchaseOrderStaffApprovalForm = ({
thousandSeparator=',' thousandSeparator=','
decimalSeparator='.' decimalSeparator='.'
inputPrefix={'Rp'} inputPrefix={'Rp'}
bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.price || 0, 'id-ID', 2, 2)}`} bottomLabel={
type === 'edit'
? `Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.price || 0, 'id-ID', 2, 2)}`
: undefined
}
isError={ isError={
isRepeaterInputError( isRepeaterInputError(
formItemIndex, formItemIndex,
@@ -858,7 +870,11 @@ const PurchaseOrderStaffApprovalForm = ({
thousandSeparator=',' thousandSeparator=','
decimalSeparator='.' decimalSeparator='.'
inputPrefix={'Rp'} inputPrefix={'Rp'}
bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.total_price || 0, 'id-ID', 2, 2)}`} bottomLabel={
type === 'edit'
? `Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.total_price || 0, 'id-ID', 2, 2)}`
: undefined
}
isError={ isError={
isRepeaterInputError( isRepeaterInputError(
formItemIndex, formItemIndex,
@@ -1132,7 +1148,7 @@ const PurchaseOrderStaffApprovalForm = ({
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting || isRejected}
> >
Submit Submit
</Button> </Button>
@@ -78,14 +78,14 @@ export const PurchaseRequestFormSchema: Yup.ObjectSchema<PurchaseRequestFormSche
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
credit_term: Yup.number()
.required('Jangka waktu kredit wajib diisi!')
.min(0, 'Jangka waktu kredit tidak boleh kurang dari 0!')
.typeError('Jangka waktu kredit wajib diisi!'),
supplier_id: Yup.number() supplier_id: Yup.number()
.required('Supplier wajib dipilih!') .required('Supplier wajib dipilih!')
.min(1, 'Supplier wajib dipilih!') .min(1, 'Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'), .typeError('Supplier wajib dipilih!'),
credit_term: Yup.number()
.required('Jangka waktu kredit wajib diisi!')
.min(0, 'Jangka waktu kredit tidak boleh kurang dari 0!')
.typeError('Jangka waktu kredit wajib diisi!'),
area: Yup.object({ area: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -22,13 +22,12 @@ import {
PurchaseRequestFormValues, PurchaseRequestFormValues,
getPurchaseRequestFormInitialValues, getPurchaseRequestFormInitialValues,
UpdatePurchaseRequestFormSchema, UpdatePurchaseRequestFormSchema,
} from './PurchaseRequestForm.schema'; } from '@/components/pages/purchase/form/request/PurchaseRequestForm.schema';
import { import {
SupplierApi, SupplierApi,
AreaApi, AreaApi,
LocationApi, LocationApi,
WarehouseApi, WarehouseApi,
ProductApi,
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier'; import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';

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