Compare commits

...

303 Commits

Author SHA1 Message Date
Rivaldi A N S ad6c25d8b6 Merge branch 'dev/randy' into 'fix/FE/US-74/TASK-270-fixing-periode-project-flock'
[FE/FE][US#74/TASK#270] Fixing Project Flock

See merge request mbugroup/lti-web-client!57
2025-11-25 04:14:48 +00:00
randy-ar 034d185b84 fix(FE): make text area debounce input for lag textarea input issue 2025-11-24 17:18:16 +07:00
randy-ar d769bfe452 fix(FE-270): adjust endpoint get next period project flock 2025-11-23 13:34:45 +07:00
randy-ar a26665e4ac fix(FE-179): prevent qty input greater than sales order qty 2025-11-22 12:58:55 +07:00
randy-ar eaaed9521b fix(FE-179-220): Adjust form add and edit delivery, add validation to prevent duplicate product 2025-11-22 12:04:46 +07:00
randy-ar b198f24b75 Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into dev/randy 2025-11-21 13:35:03 +07:00
randy-ar b7c3b9313c fix(FE-169-177): Allow Drafted Marketing to be rejected 2025-11-21 13:33:40 +07:00
Adnan Zahir fdf680bc38 Merge branch 'feat/FE/US-159/marketing-sales-order' into 'development'
[FE/FE][US#159] Sales Order

See merge request mbugroup/lti-web-client!59
2025-11-21 10:08:11 +07:00
Rivaldi A N S e9d9897e1d Merge branch 'dev/randy' into 'feat/FE/US-159/marketing-sales-order'
[FE/FE][US#159/TASK#166-167-168-169-176-177-271] Adding Feature Sales Order

See merge request mbugroup/lti-web-client!56
2025-11-21 03:04:03 +00:00
randy-ar 70521330e4 fix(FE): resolve merge conflict 2025-11-21 09:45:40 +07:00
Adnan Zahir e69e5da2c3 Merge branch 'feat/FE/US-79/egg-grading' into 'development'
[FEAT/FE][US#78|US#79] Add Feature Daily Recording Laying, Grading and Adjusting Recording Growing

See merge request mbugroup/lti-web-client!58
2025-11-21 09:15:02 +07:00
Rivaldi A N S e451a128c5 Merge branch 'feat/FE/US-78/TASK-170-174-slicing-ui-and-validation-create-daily-recording-laying-form' into 'feat/FE/US-79/egg-grading'
[FEAT/FE][US#78|US#79] Add Feature Daily Recording Laying, Grading and Adjusting Recording Growing

See merge request mbugroup/lti-web-client!51
2025-11-21 02:05:18 +00:00
randy-ar 23ab4b15e1 fix(FE-270): delete relations project flock to flock data master 2025-11-21 01:28:57 +07:00
randy-ar d523a01e34 fix(FE-270): adjust flock_name in select input and fixing project flock approval 2025-11-21 00:31:25 +07:00
randy-ar af939ee225 feat(FE-272): adding DO PDF export 2025-11-20 19:43:50 +07:00
randy-ar 391b355e8d feat(FE-181-179-220-271): adding SO export PDF and adjusting delivery form 2025-11-20 18:15:42 +07:00
rstubryan 862e056950 chore(FE-Storyless): prettier format 2025-11-20 13:07:51 +07:00
rstubryan 1310c7401c feat(FE-170,174): enhance number formatting in RecordingForm validation messages 2025-11-20 11:25:36 +07:00
rstubryan d0f2fefe1c feat(FE-170,174): add validation for total chick quantity in RecordingForm 2025-11-20 11:18:48 +07:00
rstubryan 6cb517ac92 fix(FE-174): correct parameter name for next day recording API request 2025-11-20 10:58:32 +07:00
rstubryan c698893f88 feat(FE-170,174,175): implement next day recording functionality in RecordingForm 2025-11-20 10:31:24 +07:00
rstubryan cb236c191b refactor(FE-170,174): adjust layout of action buttons in RecordingForm for better responsiveness 2025-11-20 09:20:39 +07:00
rstubryan ac764c9d3b refactor(FE-170,174): clean up grading redirect in RecordingTable action 2025-11-20 09:08:06 +07:00
randy-ar b33e7a1919 feat(FE-181-179-220): Slicing UI, Client Side Validation and API Integration for Delivery Order 2025-11-20 00:57:07 +07:00
rstubryan 28a5343592 refactor(FE-170): update tableWrapperClassName to allow overflow visibility 2025-11-19 22:41:02 +07:00
rstubryan d3c3d9c9c6 feat(FE-170,174): add notes input to approval and rejection confirmation modals 2025-11-19 20:25:06 +07:00
rstubryan 42253d123b feat(FE-170,174): enhance approval and rejection modals to include notes input 2025-11-19 20:07:26 +07:00
rstubryan 539b329b6f refactor(FE-170,174): refine RecordingTable approval logic and remove unused tooltips 2025-11-19 19:47:55 +07:00
rstubryan 427887a0e0 feat(FE-170,174): update RecordingTable to restrict selection and approval to GROWING category only 2025-11-19 19:34:46 +07:00
rstubryan 7e58e46254 feat(FE-170,174): add loading state and improve validation messages in GradingForm 2025-11-19 19:17:32 +07:00
rstubryan c876824c8f feat(FE-170,174,175): add validation for incomplete grading in GradingForm 2025-11-19 19:05:45 +07:00
rstubryan 9c69369a51 feat(FE-170): remove total_weight from body_weights and update validation logic in RecordingForm 2025-11-19 18:04:15 +07:00
rstubryan 7b28e47c68 refactor(FE-170): streamline RecordingTable component by removing unused imports and state management for area, location, and kandang 2025-11-19 17:23:04 +07:00
randy-ar 429f2b9109 fix(FE): adding capacity to kandang and change confirmation modal marketing with note 2025-11-19 13:00:21 +07:00
randy-ar 8662bcb63b fix(FE): resolve merge conflict 2025-11-19 10:30:21 +07:00
randy-ar f68e59e8c7 fix(FE): fixing table flickering when input form value 2025-11-19 10:21:59 +07:00
rstubryan 14b7f06369 fix(resolve): fix resolve MR 2025-11-18 11:17:20 +07:00
rstubryan 4dd50622a9 feat(FE-170,174,175): enhance grading button with consumable eggs validation and update rejection notes 2025-11-18 11:11:32 +07:00
rstubryan 6022ff2dae feat(FE-170,174,175): add tooltip for grading button based on consumable eggs validation 2025-11-18 10:48:41 +07:00
rstubryan 9164b985b1 feat(FE-170,174,175): implement approval lines for growing and laying recording categories 2025-11-18 10:37:10 +07:00
rstubryan 38cab1464c refactor(FE-170,174,175): add layout component and enhance API data fetching limits 2025-11-18 09:40:35 +07:00
Adnan Zahir 71b7598f87 Merge branch 'feat/FE/US-163/expense-request' into 'development'
[FEAT/FE][US#163] Expense Request

See merge request mbugroup/lti-web-client!55
2025-11-18 09:25:50 +07:00
Rivaldi A N S 075e5e452f Merge branch 'feat/FE/US-163/TASK-188-193-195-196-198-expense-request' into 'feat/FE/US-163/expense-request'
[FEAT/FE][US#163/TASK#188-193-195-196-198] Expense Request

See merge request mbugroup/lti-web-client!54
2025-11-17 09:15:04 +00:00
ValdiANS 8cd054e6aa Merge branch 'development' into feat/FE/US-163/expense-request 2025-11-17 16:04:35 +07:00
randy-ar a9bdb6c36e feat(FE-177): Integrate API sales order and fixing sales order initial state 2025-11-17 15:59:31 +07:00
ValdiANS 8b02d0df1c chore(FE-199): add reference_number, po_number, and approval property 2025-11-17 14:57:55 +07:00
ValdiANS 470cdb8b02 chore(FE-199): create Expense dummy data 2025-11-17 14:56:25 +07:00
ValdiANS da40e7d7be chore(FE-196): create expense request approval line 2025-11-17 14:55:27 +07:00
ValdiANS c58dde960c chore(FE-188,193): add IDR prefix 2025-11-17 14:23:35 +07:00
ValdiANS 4e88e76538 feat(FE-193): add existing documents link 2025-11-17 14:10:08 +07:00
ValdiANS e6ac11893a chore(FE-198): create UploadRequestDocumentsFormSchema and UploadRequestDocumentsFormValues 2025-11-17 14:07:14 +07:00
ValdiANS 83f1ba46a7 chore(FE-188,193): adjust ExpenseKandangsTable component 2025-11-17 14:05:24 +07:00
ValdiANS fc76b44279 feat(FE-195,196): create RealizationStatusBadge component 2025-11-17 14:00:16 +07:00
ValdiANS dbe6ced602 feat(FE-195,196): create ExpenseStatusBadge component 2025-11-17 13:58:37 +07:00
ValdiANS f01e764d9c feat(FE-195): add filter and approve/reject functionality 2025-11-17 13:56:26 +07:00
ValdiANS ac227f7780 feat(FE-196): create ExpenseDetail component for expense detail page 2025-11-17 13:48:07 +07:00
ValdiANS 6067c00219 feat(FE-193): create Expense Edit page 2025-11-17 13:40:40 +07:00
ValdiANS a151abfbe9 feat(FE-196): create Expense Detail page 2025-11-17 11:58:34 +07:00
ValdiANS e14d10c503 chore(FE-193,196): create expense detail layout file 2025-11-17 11:57:44 +07:00
randy-ar d3c4706d87 refactor(FE-177-166-167): separate table repeater component and adjust data types with new API Payload 2025-11-16 23:19:28 +07:00
randy-ar 3fdb10ec7f feat(FE-177): refactor sales order management with new schema and API integration 2025-11-14 15:52:58 +07:00
ValdiANS 1ee0454e6b Merge branch 'development' into feat/FE/US-163/TASK-188-193-198-slicing-expense-request-form 2025-11-14 13:41:47 +07:00
rstubryan b6ac8026c7 chore: prettier format 2025-11-13 15:43:41 +07:00
Adnan Zahir c6f881c78d Merge branch 'feat/FE/US-75-chick-in-doc' into 'development'
[FEAT/FE][US#75] Refactor Chick In DOC

See merge request mbugroup/lti-web-client!52
2025-11-13 15:19:16 +07:00
Rivaldi A N S 18b036285a Merge branch 'dev/randy' into 'feat/FE/US-75-chick-in-doc'
[FIX/FE][US#17/TASK#238-239-240] Resolve Merge Conflict

See merge request mbugroup/lti-web-client!53
2025-11-13 07:54:40 +00:00
rstubryan c7bad200ae chore: prettier format 2025-11-13 14:30:24 +07:00
rstubryan a2dd781140 chore: prettier format 2025-11-13 14:28:46 +07:00
randy-ar 10976452f5 fix(FE-85): remove debug message in flock edit page 2025-11-13 14:25:44 +07:00
ValdiANS 6ffb6e1560 chore: prettier format 2025-11-13 14:25:03 +07:00
ValdiANS 138c97a695 Merge branch 'development' into feat/FE/US-163/TASK-188-193-198-slicing-expense-request-form 2025-11-13 14:20:28 +07:00
randy-ar 56f57c4a6b fix(FE): resolve merge conflict 2025-11-13 14:12:25 +07:00
rstubryan c2479ad248 chore: prettier format 2025-11-13 14:08:35 +07:00
Adnan Zahir 7478c2597f Merge branch 'feat/FE/US-77/transfer-to-laying' into 'development'
[FEAT/FE][US#77] Transfer to Laying - API Integration

See merge request mbugroup/lti-web-client!50
2025-11-13 12:33:30 +07:00
Rivaldi A N S 5975340c3d Merge branch 'dev/randy' into 'feat/FE/US-75-chick-in-doc'
[FEAT/FE][US#75/TASK#238-239-240] Refactor Chick In DOC

See merge request mbugroup/lti-web-client!48
2025-11-13 04:53:00 +00:00
randy-ar e5318fd6b5 refactor(FE-238-86): change useApprovalSteps params, and minimize fetching data 2025-11-13 11:18:11 +07:00
randy-ar def7ee4a0b fix(FE-238): hide approval button when chickin on submission 2025-11-13 10:46:11 +07:00
randy-ar 4485ea8181 fix(FE-238): mengubah ui form chick in 2025-11-13 09:54:51 +07:00
rstubryan 57ca050100 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-78/TASK-170-174-slicing-ui-and-validation-create-daily-recording-laying-form 2025-11-13 09:34:52 +07:00
rstubryan b64ab6567b feat(FE-170,175): simplify approval status logic in GradingForm and RecordingForm 2025-11-13 09:23:29 +07:00
rstubryan 478ca186d3 feat(FE-170,175): add approval steps and status display in GradingForm and RecordingForm 2025-11-12 23:41:22 +07:00
rstubryan 47262adaf1 feat(FE-170,174,175): implement approval steps in RecordingForm and remove unused form step status 2025-11-12 23:12:22 +07:00
rstubryan e2249cf73a chore(FE-Storyless): temp fix resolve issue error on flock name 2025-11-12 22:24:06 +07:00
rstubryan 0e7c178736 feat(FE-170): enhance RecordingForm to include kandang_id from lookup and simplify product labels 2025-11-12 22:13:17 +07:00
randy-ar fa3ba46810 refactor(FE): menambahkan parameter params useApprovalSteps dan return rawData 2025-11-12 17:03:37 +07:00
randy-ar 6670f1e31b refactor(FE-238-239-240): implement approval workflow chickin & project flock, membuat custom hook useApprovals, dan handling error format approvals 2025-11-12 15:25:14 +07:00
randy-ar b2f4317c08 refactor(FE-238-239-240): implement approval workflow chickin & project flock, membuat custom hook useApprovals, dan handling error format approvals 2025-11-12 15:24:44 +07:00
Rivaldi A N S b9fb3c8311 Merge branch 'feat/FE/US-77/TASK-149-transfer-to-laying-api-integration' into 'feat/FE/US-77/transfer-to-laying'
[FEAT/FE][US#77/TASK#149] API Integration

See merge request mbugroup/lti-web-client!49
2025-11-12 06:48:25 +00:00
ValdiANS 305ad67cb4 chore: run format before commit 2025-11-12 13:39:48 +07:00
ValdiANS e3ecf5dc50 feat(FE-149): adjust BaseTransferToLaying and CreateTransferToLayingPayload structure based on the API 2025-11-12 13:38:12 +07:00
ValdiANS bc8ba1df9c feat(FE-149): add flock_name and kandangs.project_flock_kandang_id field 2025-11-12 13:37:05 +07:00
ValdiANS e7d2c3bc13 feat: add capacity to kandang 2025-11-12 13:36:33 +07:00
ValdiANS b7a055888b chore: remove unnecessary code 2025-11-12 13:36:22 +07:00
ValdiANS 8d09aec66a feat(FE-149): integrate TransferToLayingService to API 2025-11-12 13:36:12 +07:00
ValdiANS 776b809931 chore: change project flock API endpoint 2025-11-12 13:35:24 +07:00
ValdiANS c6fb707a9f chore(FE-149): add TRANSFER_TO_LAYING_APPROVAL_LINE constant 2025-11-12 13:34:49 +07:00
ValdiANS 569a8b495b feat(FE-149): integrate TransferToLayingForm to API 2025-11-12 13:34:25 +07:00
ValdiANS 1ff1e53e02 feat(FE-149): create getTransferToLayingFormInitialValues and getFilledTransferToLayingFormInitialValues helper function 2025-11-12 13:32:23 +07:00
ValdiANS 8e3282bb7d feat(FE-149): integrate TransferToLayingsTable to API 2025-11-12 13:31:35 +07:00
ValdiANS 3c0bd647a8 chore: add capacity dummy data 2025-11-12 13:30:35 +07:00
ValdiANS 557e20cffe chore: set approval status to idle if previous status is rejected 2025-11-12 13:25:58 +07:00
ValdiANS 5124c1b66a chore: create ConfirmationModalWithNotes component 2025-11-12 13:24:51 +07:00
ValdiANS c9092f36e3 chore: add children prop 2025-11-12 13:23:20 +07:00
ValdiANS 73ab5703db chore: update DateInput component 2025-11-12 13:19:00 +07:00
ValdiANS 2959295bfa chore: add enableRowSelection prop 2025-11-12 13:18:26 +07:00
ValdiANS 03b16248e5 chore: update Modal component 2025-11-12 13:15:47 +07:00
ValdiANS 963377199f feat(FE-149): integrate transfer to laying edit page to API 2025-11-12 13:11:30 +07:00
ValdiANS 4bbf6fd7f8 feat(FE-149): integrate transfer to laying detail page to API 2025-11-12 13:10:18 +07:00
ValdiANS 4422b7391a chore(FE-149): install react-day-picker 2025-11-12 13:04:09 +07:00
randy-ar 5dccaf40cb refactor(FE-88): memisahkan file api project flock & penyesuaian tipe data dan paylod dengan BE 2025-11-12 09:04:23 +07:00
kris f00e772018 Update .gitlab-ci.yml file 2025-11-11 06:07:24 +00:00
randy-ar f63d3d3870 refactor(FE): Refactor project flock api & implement approval component in project flock details 2025-11-10 17:05:24 +07:00
rstubryan c7022ee200 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-78/TASK-170-174-slicing-ui-and-validation-create-daily-recording-laying-form 2025-11-10 08:37:23 +07:00
rstubryan 3ac0672f7e feat(FE-170,175): update RecordingForm to include selectedKandang in product URL generation and options filtering 2025-11-10 08:36:55 +07:00
randy-ar 9f4f140018 refactor(FE-239-238): Refactor UI & API Integration For Form Chickin & Chickin Details 2025-11-10 06:07:02 +07:00
randy-ar e0c347c3d5 refactor(FE-87-106): refactor api integration untuk project flock dan project flock kandang 2025-11-10 04:08:08 +07:00
rstubryan 4f88f26b71 feat(FE-170,175): update approval logic in RecordingTable to include additional conditions for egg grading status 2025-11-07 14:56:28 +07:00
rstubryan 80fcabde7e feat(FE-170,175): extend GradingForm to support additional grading data and improve form initialization 2025-11-07 14:56:03 +07:00
rstubryan 2e35462300 feat(FE-170): change Cancel button to Reset in RecordingForm for improved form handling 2025-11-07 08:51:54 +07:00
rstubryan f8f613ec9d refactor(FE-170,175): update approval logic in RecordingForm to consider grading data for LAYING category 2025-11-06 23:45:02 +07:00
rstubryan a1bf38023c feat(FE-170,175): enhance approval logic and tooltips for LAYING category recordings in RecordingTable 2025-11-06 23:40:13 +07:00
rstubryan f032f71136 feat(FE-170,175): implement payload creation for growing and laying recordings in RecordingForm 2025-11-06 23:27:46 +07:00
rstubryan 2e5530cf91 refactor(FE-170): simplify confirmation modal text in RecordingTable 2025-11-06 23:15:50 +07:00
rstubryan c45217e98e feat(FE-170,175): add grading data handling in RecordingForm and update types 2025-11-06 22:40:04 +07:00
rstubryan 62c16bb9d1 feat(FE-170,175): enhance RecordingTable with grading completion checks and approval logic 2025-11-06 22:28:31 +07:00
rstubryan c012668340 feat(FE-175): update grading logic in GradingForm to utilize konsumsiBaikEggId 2025-11-06 21:38:52 +07:00
ValdiANS 512e016b5e chore(FE-199): adjust Expense type 2025-11-06 21:11:18 +07:00
ValdiANS 57ffd50558 feat(FE-195): set expense table column header and cell 2025-11-06 21:10:50 +07:00
rstubryan 5245d44a79 feat(FE-170,175): add module_id prop to approval history modal in RecordingTable 2025-11-06 19:13:11 +07:00
rstubryan b39e8325f8 feat(FE-170): simplify action buttons in RecordingForm for detail view 2025-11-06 17:47:34 +07:00
rstubryan b24c9d8336 feat(FE-170): add approval logic to RecordingForm for detail view actions 2025-11-06 17:17:49 +07:00
rstubryan d8b076d105 feat(FE-170,175): enhance RecordingForm and RecordingTable with approval logic and UI improvements 2025-11-06 17:12:30 +07:00
randy-ar fcc2fced06 feat(FE-166-167-168): slicing ui create, edit dan detail sales order 2025-11-06 16:57:17 +07:00
rstubryan ffa11fa20a feat(FE-170): add grading button for laying category in RecordingTable 2025-11-06 15:46:21 +07:00
ValdiANS e4b61cfe05 chore(FE-199): create Expense API service with dummy data 2025-11-06 15:30:22 +07:00
ValdiANS c59a88bbcb chore(FE-188,193): create formik helper function utils and create removeArrayItemAndSync formik helper function 2025-11-06 15:29:42 +07:00
ValdiANS a8b1f6f8c2 chore(FE-188,193): create convertRowSelectionArrToObj and convertRowSelectionObjToArr helper function 2025-11-06 15:29:15 +07:00
ValdiANS 6e582c4e7c chore(FE-188,193): create ACCEPTED_FILE_TYPES constant 2025-11-06 15:29:00 +07:00
ValdiANS f011f5b7f9 feat(FE-188,193): create ExpenseRequestKandangDetailExpense component 2025-11-06 15:28:42 +07:00
ValdiANS 1d4a16cd0b feat(FE-198): add kandangs, existing_documents, kandangExpenses to ExpenseFormSchemaType and create helper function to get form initial values 2025-11-06 15:28:12 +07:00
ValdiANS 2a71734583 feat(FE-188,193): add Vendor, Request Documents, and Kandang Detail Expense input 2025-11-06 15:26:54 +07:00
ValdiANS e9eee6eb3e feat(FE-188,193): create ExpenseKandangsTable component 2025-11-06 15:22:03 +07:00
rstubryan 069ab98da1 refactor(FE-170): refactor RecordingForm navigation and action buttons for improved user experience 2025-11-06 15:19:01 +07:00
rstubryan 90dd26064d feat(FE-170,175): enhance GradingForm with additional recording details and improve UI for grading information 2025-11-06 15:12:04 +07:00
rstubryan de9ec716f5 feat(FE-170,175): add toast notification for grading exceeding available eggs and enhance UI for grading information 2025-11-06 14:53:21 +07:00
rstubryan 501222a4ee feat(FE-170,175): enhance GradingForm with total egg consumption display and validation for grading limits 2025-11-06 14:42:17 +07:00
rstubryan 62c595bdf6 feat(FE-170,175): enhance product fetching in RecordingForm with additional filters and limits 2025-11-06 14:26:45 +07:00
rstubryan 06eec88d56 feat(FE-170,175): implement form steps and navigation for LAYING category in RecordingForm 2025-11-06 14:13:16 +07:00
randy-ar 158971d904 feat(FE-166-169): Slicing UI Penjualan Form dan Client side validation 2025-11-06 13:45:52 +07:00
rstubryan 6d8d608cc9 refactor(FE-170): add support for 'UPDATED' action in RecordingTable with corresponding status text 2025-11-06 13:27:18 +07:00
rstubryan c9edc407b4 refactor(FE-174,175): update approve method to allow custom notes in RecordingForm 2025-11-06 13:08:26 +07:00
rstubryan c774480a5a refactor(FE-170): enhance RecordingForm with approve and reject buttons for detail view 2025-11-06 13:05:48 +07:00
rstubryan fa42f9b941 refactor(FE-170): clean up imports and enhance state management in RecordingForm 2025-11-06 10:58:14 +07:00
rstubryan 3d3569bbc0 feat(FE-170,175): integrate ProjectFlockKandang API and enhance RecordingForm with detailed project flock and kandang options 2025-11-06 10:33:45 +07:00
rstubryan a524dec16d refactor(FE-174): add ProjectFlockKandang API and enhance type definitions 2025-11-06 09:53:32 +07:00
rstubryan 4e40aba544 refactor(FE-170): refine product name checks in RecordingForm by removing specific keywords 2025-11-06 09:21:01 +07:00
rstubryan 6c164313de refactor(FE-Storyless): correct endpoint URL for project flocks in RecordingService 2025-11-06 09:18:52 +07:00
rstubryan be98655c75 feat(FE-170): improve product name checks in RecordingForm for better matching 2025-11-05 16:43:18 +07:00
rstubryan 333212a1de feat(FE-170,174,175): enhance RecordingForm with product warehouse integration and improve data handling 2025-11-05 15:39:19 +07:00
rstubryan a33a4167c1 refactor(FE-170,175): update approval notes handling in RecordingTable for better clarity 2025-11-05 14:00:48 +07:00
rstubryan 2cf8bcf746 feat(FE-170): refactor approval history button and badge components in RecordingTable 2025-11-05 14:00:02 +07:00
rstubryan b1457a5feb feat(FE-170,174,175): add approval history modal and integrate approval API in RecordingTable 2025-11-05 13:41:55 +07:00
rstubryan fac9d5fa42 feat(FE-170,174): update validation messages and labels for egg product fields in RecordingForm 2025-11-05 13:13:46 +07:00
rstubryan 4454eac8af feat(FE-170,175): implement multi-select approval and rejection for recordings in RecordingTable 2025-11-05 10:01:07 +07:00
rstubryan fa36c10c01 feat(FE-170,175): add approval and rejection functionality with confirmation modals in RecordingTable 2025-11-05 09:46:38 +07:00
rstubryan 02cc4a759d feat(FE-Storyless): add approval workflows for project flocks and recordings 2025-11-05 08:43:10 +07:00
rstubryan 04a1f5e014 feat(FE-170,174): add total_weight field and update calculations in body_weights for RecordingForm 2025-11-04 16:23:29 +07:00
rstubryan b7ab537b95 feat(FE-170): add selection checkboxes and enhance project details in RecordingTable 2025-11-04 16:07:02 +07:00
rstubryan b0665b2541 refactor(FE-174): rename name to flock_name in BaseProjectFlock type for clarity 2025-11-04 16:06:39 +07:00
rstubryan 77e3fe12c3 refactor(FE-170): update API endpoint and rename label for project flock in RecordingForm 2025-11-04 16:06:04 +07:00
ValdiANS 6a070e39da chore(FE-188): updateDropFileInput component 2025-11-04 15:54:13 +07:00
ValdiANS 3b69286a8e chore(FE-188): add DropFileInput component 2025-11-04 15:47:01 +07:00
ValdiANS 0de2e87221 fix(FE-188): required symbol space 2025-11-04 15:46:49 +07:00
ValdiANS 9daa6aaf8c chore(FE-188): update Modal component 2025-11-04 15:46:30 +07:00
ValdiANS a12ae51f3a fix(FE-188): rename to ExpenseRequestForm 2025-11-04 15:46:08 +07:00
ValdiANS 17c316c4af fix(FE-188): required symbol space 2025-11-04 15:45:36 +07:00
ValdiANS c438a8f6aa chore: install react-dropzone 2025-11-04 15:39:55 +07:00
rstubryan 966ad7545c refactor(FE-174): rename project_flock_kandangs_id to project_flock_kandang_id for consistency in RecordingForm 2025-11-04 15:20:33 +07:00
rstubryan 0a17249fb9 refactor(FE-174): rename project_flock_kandangs_id to project_flock_kandang_id for consistency in RecordingForm schema 2025-11-04 14:54:07 +07:00
rstubryan b19be7dd4b refactor(FE-174): update recording types to include approval and egg grading fields for enhanced data handling 2025-11-04 14:53:07 +07:00
randy-ar d8637923bd refactor(FE-106-91-339-238): Slicing UI Chickin DOC Refactored 2025-11-04 13:24:10 +07:00
ValdiANS afa0c6c83f chore: rename ExpenseForm to ExpenseRequestForm 2025-11-03 16:16:12 +07:00
ValdiANS 1afa6f7fad chore: rename ExpenseForm.schema.ts to ExpenseRequestForm.schema.ts 2025-11-03 16:15:19 +07:00
rstubryan e4ab86c3eb refactor(FE-170): streamline RecordingForm component by native card and optimizing layout for better usability 2025-11-03 10:44:37 +07:00
rstubryan d8599a850a refactor(FE-170): streamline RecordingTable component by removing unused state and optimizing layout for better usability 2025-11-03 10:40:13 +07:00
ValdiANS ae560c2451 chore: run prettier formatting before every commit 2025-11-03 10:31:44 +07:00
rstubryan bcb4d4492d refactor(FE-170): replace FormHeader with custom header in GradingForm and RecordingForm for improved layout and navigation 2025-11-03 10:30:33 +07:00
ValdiANS 176e1e7cb8 chore: install react-day-picker 2025-11-03 10:29:23 +07:00
ValdiANS f6d4ef4697 chore: update DateInput component 2025-11-03 10:27:51 +07:00
rstubryan e9e8ad771e refactor(FE-174): enhance GradingForm and RecordingForm with improved error handling and modal integration for delete actions 2025-11-03 10:22:35 +07:00
randy-ar 219cbedbcd refactor(FE-238-106): change dateinput and create chickin page and pull development 2025-11-03 10:12:15 +07:00
randy-ar 3eb2930640 refactor(FE-238-106): change dateinput and create chickin page 2025-11-03 10:09:12 +07:00
rstubryan 2ba23654ce refactor(FE-Storyless): simplify RowOptionsMenu by using RowOptionsMenuWrapper and enhance button layout 2025-11-03 09:44:50 +07:00
rstubryan ac11559754 chore(FE-Storyless): remove inputmask package and its type definitions from dependencies 2025-11-03 09:35:52 +07:00
rstubryan fa1552e276 fix(resolve): fix resolve merge 2025-11-03 09:35:09 +07:00
rstubryan 9a4d961dee refactor(FE-174): implement create, update, and delete grading methods in RecordingApi and update handlers 2025-11-03 09:28:20 +07:00
ValdiANS 33c0d5513c Merge branch 'development' into feat/FE/US-163/expense-request 2025-11-03 09:26:03 +07:00
rstubryan c26e174885 refactor(FE-174): enhance RecordingApi by adding approve and reject methods for better approval handling 2025-11-03 09:00:49 +07:00
rstubryan b976600099 refactor(FE-170,174): update GradingForm to include recording_egg_id in grading data enhance validation schema 2025-11-02 23:22:37 +07:00
rstubryan aac7215be7 refactor(FE-170,174): update schema and validation for stocks and depletions; rename usage_qty to qty for consistency 2025-11-02 23:14:07 +07:00
rstubryan e116311dc2 refactor(FE-170,174): restructure schema and validation for body weights, stocks, and depletions; improve handling of input values 2025-11-02 23:04:54 +07:00
rstubryan 4afeded080 Merge branch 'dev/restu' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-78/TASK-170-174-slicing-ui-and-validation-create-daily-recording-laying-form 2025-11-02 21:53:56 +07:00
rstubryan 83757b5208 fix(resolve): fix resolve merge 2025-11-02 21:53:41 +07:00
rstubryan f335bc23eb refactor(FE-Storyless): simplify MovementForm by integrating useSelect for warehouse and supplier inputs, enhance data fetching logic 2025-11-02 21:42:03 +07:00
rstubryan d793824520 refactor(FE-Storyless): enhance MovementForm schema and validation, improve handling of product quantities and delivery costs 2025-11-02 21:33:38 +07:00
rstubryan 901b61a172 refactor(FE-Storyless): enhance ProductCategoryForm schema and validation, improve UI text and layout 2025-11-02 21:21:57 +07:00
rstubryan 39dd583e77 refactor(FE-Storyless): enhance ProductForm schema and handling, add required fields and improve validation 2025-11-02 21:15:41 +07:00
rstubryan fc3b090da5 refactor(FE-Storyless): replace TextInput with NumberInput for price and tax fields, enhance form handling 2025-11-02 20:59:37 +07:00
rstubryan 16db7af070 chore(FE-Storyless): remove inputmask and related type definitions 2025-11-02 20:14:22 +07:00
rstubryan f70433d901 refactor(FE-Storyless): remove UpdateMovementPayload type and related schema, streamline MovementForm handling 2025-11-01 10:43:43 +07:00
rstubryan 393f8a6d1b Merge remote-tracking branch 'origin/dev/restu' into dev/restu
# Conflicts:
#	src/components/pages/inventory/movement/MovementTable.tsx
2025-11-01 09:46:46 +07:00
rstubryan e73d3e0823 refactor(FE-Storyless): add product and warehouse filters with select inputs 2025-11-01 09:46:31 +07:00
rstubryan ee4a470fd2 refactor(FE-Storyless): add product and warehouse filters with select inputs 2025-11-01 09:46:06 +07:00
rstubryan 40171720fb Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu 2025-11-01 08:36:04 +07:00
rstubryan 09cb5f10aa refactor(FE-Storyless): replace FormHeader and FormActions with custom header and action buttons for improved UI 2025-11-01 08:35:46 +07:00
rstubryan 1228b45045 feat(FE-170,174): clean up RecordingForm and RecordingTable components for improved readability and maintainability 2025-10-31 17:27:10 +07:00
rstubryan 19afb80597 feat(FE-170,174): refactor GradingForm to use grading form handlers and remove approval logic 2025-10-31 17:26:56 +07:00
rstubryan 9495742cb7 feat(FE-174): add grading form handlers for creating, updating, and deleting grading records 2025-10-31 17:26:24 +07:00
rstubryan 01db13ed6c feat(FE-170,174): add GradingForm component for managing grading records 2025-10-31 15:15:48 +07:00
rstubryan 4a1f775c85 feat(FE-170): update daily recording form to redirect to grading form after successful submission 2025-10-31 15:15:32 +07:00
randy-ar 495e11c6fe fix(FE-41): Menambahkan kolom kapasitas di tabel kandang 2025-10-31 14:57:15 +07:00
ValdiANS 15893c18c9 feat(FE-199): create Expense API types 2025-10-31 14:33:28 +07:00
ValdiANS 026e60704b feat(FE-199): create Expense API service 2025-10-31 14:33:19 +07:00
ValdiANS 21b155e64b feat(FE-195): add Expense menu 2025-10-31 14:30:36 +07:00
ValdiANS 1a1bf8754e feat(FE-188): create Expense Form schema 2025-10-31 14:30:00 +07:00
ValdiANS a51c7c44ec feat(FE-188): create ExpenseForm component 2025-10-31 14:29:31 +07:00
ValdiANS 4d1241d712 feat(FE-195): create ExpenseTable component 2025-10-31 14:29:11 +07:00
ValdiANS 80747bb441 chore(FE-195): adjust ConfirmationModal primaryButton and secondaryButton props 2025-10-31 14:28:51 +07:00
ValdiANS 00f64b1897 chore: add string as the valueKey and labelKey type 2025-10-31 14:28:04 +07:00
ValdiANS 01bfe1cc3b chore: export ButtonProps interface 2025-10-31 14:27:13 +07:00
ValdiANS a0cf6c0f56 feat(FE-188): create Add Expense page 2025-10-31 14:26:52 +07:00
ValdiANS c72befb5b4 feat(FE-195): create Expense page 2025-10-31 14:25:01 +07:00
rstubryan 3a52d800e0 feat(FE-174): add grading functionality to daily recording form with validation 2025-10-31 14:01:51 +07:00
randy-ar b6991652ac feat(FE-169): Slicing UI Index Pengajuan Sales & Define Data Type for Marketing 2025-10-31 13:57:30 +07:00
rstubryan c486d6cf81 feat(FE-170): implement form steps and navigation for daily recording form 2025-10-31 13:52:54 +07:00
rstubryan e7ed3d6ab2 feat(FE-174): add FormStepStatus type to enhance daily recording form state management 2025-10-31 13:52:36 +07:00
rstubryan 2d30514d64 refactor(FE-170,174): simplify daily recording form by removing unused flock period logic 2025-10-31 13:08:55 +07:00
rstubryan 59b0eeea2b feat(FE-170): add egg handling and validation to daily recording form 2025-10-31 00:02:04 +07:00
rstubryan 0e77597a70 refactor(FE-174): add grading and egg handling to daily recording form 2025-10-31 00:01:46 +07:00
rstubryan b7de8b40d8 feat(FE-170,174): implement project flock kandang selection and validation in daily recording form 2025-10-30 21:41:14 +07:00
rstubryan 87295252aa refactor(FE-174): remove unused pending_qty field from stocks in recording type definition 2025-10-30 21:41:01 +07:00
randy-ar 7ab96fac8b Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into dev/randy 2025-10-30 21:24:20 +07:00
randy-ar 99194eaf80 refactor(FE-92-87): mengganti select input dengan reuseable component 2025-10-30 21:23:37 +07:00
rstubryan 50196493e3 feat(FE-170,174): add depletion products handling and update select input options in daily recording form 2025-10-30 20:21:37 +07:00
rstubryan 75348620d7 feat(FE-170,174): enhance daily recording form with weight validation and dynamic average weight calculation 2025-10-30 19:09:21 +07:00
rstubryan 4cb045de6c refactor(US-170,174): update recording types and validation schema for daily recording form 2025-10-30 18:10:37 +07:00
rstubryan ae0cca778e chore(FE-Storyless): remove inputmask and its type definitions for cleanup 2025-10-30 18:10:04 +07:00
rstubryan 74503f12d6 fix(FE-Storyless): update SelectInput value handling to use undefined instead of null for better compatibility 2025-10-30 14:01:13 +07:00
rstubryan c832c4adeb fix(resolve): resolve merge issue 2025-10-29 15:56:57 +07:00
rstubryan eda3f0f1be chore(FE-Storyless): update Prettier to version 3.6.2 and remove .prettierrc from .gitignore 2025-10-29 15:52:34 +07:00
rstubryan c7b04c5bc6 feat(FE-137): integrate flock periods data fetching in RecordingForm for accurate recording validation 2025-10-28 10:58:37 +07:00
rstubryan c37950a230 refactor(FE-137): optimize recordedProjectFlockIds calculation to filter today's recordings 2025-10-28 10:44:08 +07:00
rstubryan 7da95b80b0 refactor(FE-Storyless): conditionally handle onChange prop in SelectInput for better flexibility 2025-10-28 08:58:01 +07:00
rstubryan c74ed18a16 refactor(FE-137): enable clearable option for select inputs in RecordingForm 2025-10-27 14:15:48 +07:00
rstubryan 15e6372c30 feat(FE-137): add product flag badges to RecordingForm for enhanced visibility 2025-10-27 13:03:37 +07:00
rstubryan 6dd3593f70 feat(FE-137): integrate Badge component to display project flock period in RecordingForm 2025-10-27 12:57:00 +07:00
rstubryan 5d376f8783 refactor(FE-137): remove unnecessary padding from SelectInput for improved layout 2025-10-27 12:56:38 +07:00
rstubryan 304d14a6fe refactor(FE-137): remove 'Periode' column from RecordingTable for cleaner display 2025-10-27 11:57:46 +07:00
rstubryan 0b0ecd3bc4 refactor(FE-137): replace stock availability text with Badge component in MovementForm 2025-10-27 11:25:15 +07:00
rstubryan 58369b8ffa refactor(FE-137): simplify stock display in MovementForm and RecordingForm, enhance input handling in SelectInput 2025-10-27 11:05:06 +07:00
rstubryan 943c0e05b9 refactor(FE-137): conditionally render location SelectInput in RecordingForm based on type 2025-10-27 06:50:48 +07:00
rstubryan 9143248e1d refactor(FE-137): remove redundant status column from RecordingTable 2025-10-27 06:28:51 +07:00
rstubryan 4b9d0d2064 refactor(FE-137): enhance RecordingForm validation to prevent duplicate project flock entries 2025-10-27 06:18:27 +07:00
rstubryan c8f596ad2a refactor(FE-137): update RecordingForm to improve project flock handling and label formatting 2025-10-27 05:54:14 +07:00
rstubryan 135fc2d5d3 feat(FE-114): update MovementForm and RecordingForm to use inputPrefix and inputSuffix for improved input handling 2025-10-25 14:24:51 +07:00
rstubryan 189c152745 feat(FE-114): add inputPrefix and inputSuffix props for enhanced input customization 2025-10-25 14:24:23 +07:00
rstubryan a0556ea1f4 refactor(FE-114): add currency prefix and unit suffix to delivery cost and body weight inputs 2025-10-25 13:53:53 +07:00
rstubryan 81ce36e326 refactor(FE-137): remove ID column from RecordingTable for cleaner presentation 2025-10-25 13:41:18 +07:00
rstubryan d7ce8c667a refactor(FE-114): simplify input handling in MovementForm and RecordingForm by removing unnecessary value normalization 2025-10-25 11:26:38 +07:00
rstubryan 6290199074 feat(FE-Storyless): integrate NumberInput and PatternInput components with react-number-format for enhanced input handling 2025-10-25 10:49:07 +07:00
rstubryan 896a0c6de2 refactor(FE-64): integrate product and supplier selection with API data fetching in MovementForm 2025-10-24 21:10:03 +07:00
rstubryan 9c5dc0dbb5 refactor(FE-137): integrate approve and reject functionality in RecordingForm with loading states and modal confirmations 2025-10-24 20:44:15 +07:00
rstubryan 81003eac63 feat(FE-137): enhance stock product selection in RecordingForm with initial values support 2025-10-24 20:37:11 +07:00
rstubryan e322e0d078 feat(FE-137): update RECORDING_FLAG_OPTIONS values for consistency in constant.ts 2025-10-24 20:29:33 +07:00
rstubryan 17e6eef0c5 feat(FE-137): add approve and reject functionality in RecordingForm with confirmation modals 2025-10-24 18:02:41 +07:00
rstubryan 6114d706ad feat(FE-137): disable input field in RecordingForm when type is 'detail' 2025-10-24 14:13:21 +07:00
rstubryan d14fa2ed2b feat(FE-137): integrate advanced filtering options in RecordingTable with dropdowns for area, location, and kandang 2025-10-24 13:53:20 +07:00
rstubryan 537fc617ff feat(FE-137): implement bulk approval and rejection functionality in RecordingTable with user feedback 2025-10-24 13:40:27 +07:00
rstubryan 7a6a35568f feat(FE-137): enhance RecordingTable to support recording deletion with user feedback and refresh functionality 2025-10-24 13:32:46 +07:00
rstubryan d2c485fdf0 feat(FE-114,137): implement stock validation in RecordingForm to manage usage limits and enhance user feedback 2025-10-24 12:45:07 +07:00
rstubryan 0c49978033 feat(FE-114,137): enhance RecordingForm to handle stock usage and depletion total changes with improved input handling 2025-10-24 12:26:33 +07:00
rstubryan 00de4782e7 feat(FE-137): simplify RecordingTable by removing unused columns and enhancing data clarity 2025-10-24 12:14:47 +07:00
rstubryan c546bd6b3c feat(FE-137): refactor RecordingTable to remove unused types and streamline data fetching 2025-10-24 11:37:25 +07:00
rstubryan 258324f092 feat(US-137): update RecordingTable to enhance data display and add new columns for project details 2025-10-24 11:36:14 +07:00
rstubryan 12a69b7c6c feat(FE-137): integrate SWR for fetching recordings and update table to display API data 2025-10-24 11:35:11 +07:00
rstubryan b148a09e84 feat(US-137): update API endpoints and default values in RecordingForm for production environment 2025-10-24 11:27:32 +07:00
rstubryan adc995dbe7 feat(US-114): enhance auto-calculation logic in RecordingForm to handle manual edits 2025-10-24 11:00:14 +07:00
rstubryan 9cbc703a63 feat(FE-114): integrate row selection functionality in RecordingTable and Table components 2025-10-24 10:18:56 +07:00
rstubryan 41e6848d75 refactor(FE-114): remove optional product_warehouse_id validation from RecordingForm schema 2025-10-24 10:08:38 +07:00
rstubryan ca5b236565 refactor(FE-114): enforce required usage amount in RecordingForm validation 2025-10-24 10:00:35 +07:00
rstubryan 714072aea1 fix(merge): resolve merge conflict 2025-10-24 09:57:38 +07:00
rstubryan a9f0696b38 refactor(FE-114): auto-populate notes with product name and enhance tooltip visibility in RecordingForm 2025-10-24 09:50:12 +07:00
rstubryan c30fcd81b2 refactor(FE-114): simplify CreateRecordingPayload structure and update validation in RecordingForm 2025-10-24 08:53:41 +07:00
rstubryan 7f5ae94706 feat(FE-114): integrate product stock fetching and selection in RecordingForm 2025-10-23 22:59:41 +07:00
rstubryan 6060ec0f7e feat(FE-114): prevent auto-calculation override during manual average weight editing in RecordingForm 2025-10-23 22:02:12 +07:00
rstubryan ef249fee12 feat(FE-114): add average weight calculation and input handling in RecordingForm 2025-10-23 21:54:06 +07:00
rstubryan 71df86c8df feat(FE-114): integrate location and project flock selection in RecordingForm 2025-10-23 21:34:40 +07:00
rstubryan d61c0ab844 feat(FE-114): integrate date time handling in RecordingForm for on-time status 2025-10-23 20:59:20 +07:00
rstubryan b653cc1dab refactor(FE-114): replace button elements with Button component for consistency and improved styling 2025-10-23 20:44:59 +07:00
rstubryan 392e211181 refactor(FE-Storyless): replace img with Image component for optimized loading 2025-10-23 19:54:17 +07:00
rstubryan cebe738beb refactor(FE-114): enhance type safety and improve checkbox input handling 2025-10-23 19:52:38 +07:00
rstubryan 6e5875a7b7 refactor(FE-Storyless): add flock_id, area_id, fcr_id, location_id, and kandang_ids to project-flock type definition 2025-10-23 19:52:21 +07:00
rstubryan db8cb56984 fix(merge): resolve conflict on merge 2025-10-23 18:24:02 +07:00
rstubryan 22f1a32e1b feat(FE-137): integrate API for daily recording with enhanced data structure and validation 2025-10-23 11:59:22 +07:00
135 changed files with 20721 additions and 6228 deletions
-3
View File
@@ -40,8 +40,5 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# prettier
.prettierrc
# idea # idea
.idea .idea
+133 -49
View File
@@ -2,61 +2,145 @@ stages:
- build - build
- deploy - deploy
variables: .build_template: &build_template
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
DOCKER_DRIVER: overlay2
BUILDKIT_PROGRESS: plain
IMAGE_NAME: "$CI_REGISTRY_IMAGE/web-lti:development_${CI_COMMIT_SHORT_SHA}"
NODE_ENV: "production"
HUSKY_SKIP_INSTALL: "1"
NEXT_PUBLIC_API_BASE_URL: "${NEXT_PUBLIC_API_BASE_URL}"
NEXT_PUBLIC_LTI_API_START_URL: "${NEXT_PUBLIC_LTI_API_START_URL}"
build-image:
stage: build stage: build
image: docker:27.0.3 image: node:20-alpine
services: cache:
- docker:dind key: npm-cache
paths:
before_script: - node_modules/
- echo "Login to registry" variables:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" NPM_CONFIG_PRODUCTION: 'false'
NODE_ENV: ''
script: script:
- | - echo "Installing dependencies..."
docker build \ - npm ci --no-audit --no-fund
--build-arg NEXT_PUBLIC_API_BASE_URL="$NEXT_PUBLIC_API_BASE_URL" \ - echo "Building Next.js static export..."
--build-arg NEXT_PUBLIC_LTI_API_START_URL="$NEXT_PUBLIC_LTI_API_START_URL" \ - npx next build
--build-arg NODE_ENV="$NODE_ENV" \ artifacts:
--build-arg HUSKY_SKIP_INSTALL="$HUSKY_SKIP_INSTALL" \ name: 'out-$CI_COMMIT_SHORT_SHA'
--cache-from "$CI_REGISTRY_IMAGE/web-lti:latest" \ paths:
-t "$IMAGE_NAME" . - out/
- docker push "$IMAGE_NAME" expire_in: 1 week
- docker image prune -af --filter "until=72h"
after_script: "echo 'Build complete: $IMAGE_NAME' && docker system prune -af || true && docker volume prune -f || true" .deploy_template: &deploy_template
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
deploy-dev:
stage: deploy stage: deploy
image: alpine:3.20 image:
name: amazon/aws-cli:latest
before_script: entrypoint: ['/bin/sh', '-c']
- apk add --no-cache openssh curl
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval $(ssh-agent -s)
- ssh-add ~/.ssh/id_rsa
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
script: script:
- ssh -o StrictHostKeyChecking=no \"$SERVER_USER@$SERVER_IP\" \"docker stop dev-web-lti || true && docker rm dev-web-lti || true && docker pull $CI_REGISTRY_IMAGE/web-lti:development_${CI_COMMIT_SHORT_SHA} && docker run -d --name dev-web-lti --network dev-lti-network -p 3002:3000 $CI_REGISTRY_IMAGE/web-lti:development_${CI_COMMIT_SHORT_SHA}\" - set -e
- aws --version
- echo "Cleaning up newline characters in AWS credentials..."
- export AWS_ACCESS_KEY_ID=$(echo $AWS_ACCESS_KEY_ID | tr -d '\r\n')
- export AWS_SECRET_ACCESS_KEY=$(echo $AWS_SECRET_ACCESS_KEY | tr -d '\r\n')
- echo "Deploying to s3://$S3_BUCKET in region $AWS_REGION"
- aws s3api head-bucket --bucket "$S3_BUCKET" --region "$AWS_REGION" || aws s3api create-bucket --bucket "$S3_BUCKET" --region "$AWS_REGION" --create-bucket-configuration LocationConstraint="$AWS_REGION"
- aws s3 sync ./out "s3://$S3_BUCKET" --delete --region "$AWS_REGION" --endpoint-url "https://s3.ap-southeast-3.amazonaws.com"
after_script: "echo 'Deploy finished for $IMAGE_NAME'" # CloudFront invalidation
- |
STATUS="success"
if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then
echo "Invalidating CloudFront cache..."
if ! aws cloudfront create-invalidation --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" --paths "/*"; then
echo "CloudFront invalidation failed."
STATUS="failed"
fi
else
echo "No CloudFront distribution specified — skipping invalidation"
fi
# Notifikasi Discord
- |
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"
if [ "$CI_COMMIT_BRANCH" = "development" ]; then
ENVIRONMENT_NAME="WEB-LTI-DEV"
elif [ "$CI_COMMIT_BRANCH" = "master" ]; then
ENVIRONMENT_NAME="WEB-LTI-PROD"
else
ENVIRONMENT_NAME="UNKNOWN"
fi
if [ "$STATUS" = "success" ]; then
COLOR=3066993
TITLE="✅ Deployment ${ENVIRONMENT_NAME} Succeeded"
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."
else
COLOR=15158332
TITLE="❌ Deployment ${ENVIRONMENT_NAME} Failed"
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` encountered issues."
fi
jq -n \
--arg title "$TITLE" \
--arg desc "$DESC" \
--arg color "$COLOR" \
--arg repo "$CI_PROJECT_PATH" \
--arg actor "$GITLAB_USER_LOGIN" \
--arg commit "$CI_COMMIT_SHA" \
--arg run_url "$RUN_URL" \
'{
username: "CI Bot - LTI WEB",
embeds: [{
title: $title,
description: $desc,
color: ($color|tonumber),
fields: [
{name: "Repository", value: $repo, inline: true},
{name: "Actor", value: $actor, inline: true},
{name: "Commit", value: $commit, inline: false},
{name: "Pipeline", value: ("[Open run](" + $run_url + ")"), inline: false}
]
}]
}' > payload.json
curl -sS -H "Content-Type: application/json" -d @payload.json "$DISCORD_WEBHOOK_URL"
# ====== DEVELOPMENT (Branch development) ======
build:dev:
<<: *build_template
rules: rules:
- if: '$CI_COMMIT_BRANCH == "development"' - if: '$CI_COMMIT_BRANCH == "development"'
environment:
name: development
variables:
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
deploy:dev:
<<: *deploy_template
needs: ['build:dev']
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
variables:
S3_BUCKET: 'dev-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E1Z8XTA8XF1GIV'
environment:
name: development
url: https://dev-lti-erp.mbugroup.id
# ====== PRODUCTION ======
# build:production:
# <<: *build_template
# rules:
# # pilih salah satu: pakai branch master ATAU pakai tags rilis
# - if: '$CI_COMMIT_BRANCH == "master"'
# # - if: '$CI_COMMIT_TAG' # kalau mau rilis via tag, uncomment ini dan hapus baris di atas
# environment:
# name: production
# deploy:production:
# <<: *deploy_template
# needs: ["build:production"]
# rules:
# - if: '$CI_COMMIT_BRANCH == "master"'
# # - if: '$CI_COMMIT_TAG' # selaras dengan rule di build:production
# variables:
# S3_BUCKET: "lti-erp.mbugroup.id"
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
# environment:
# name: production
# url: https://royalgoldcapital.com
+1
View File
@@ -1,2 +1,3 @@
npm run format
npm run lint npm run lint
npm run build npm run build
+5 -5
View File
@@ -1,4 +1,4 @@
version: "3.9" version: '3.9'
services: services:
dev-web-lti: dev-web-lti:
@@ -7,7 +7,7 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "3002:3000" - '3002:3000'
env_file: env_file:
- .env - .env
environment: environment:
@@ -19,13 +19,13 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
cpus: "3.0" cpus: '3.0'
memory: 3G memory: 3G
reservations: reservations:
cpus: "1.0" cpus: '1.0'
memory: 512M memory: 512M
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - 'host.docker.internal:host-gateway'
# Optional: aktifkan healthcheck jika punya endpoint # Optional: aktifkan healthcheck jika punya endpoint
# healthcheck: # healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/healthz || exit 1"] # test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/healthz || exit 1"]
+602 -19
View File
@@ -8,16 +8,18 @@
"name": "lti-web-client", "name": "lti-web-client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.1",
"@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"inputmask": "^5.0.9",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-dropzone": "^14.3.8",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
"react-select": "^5.10.2", "react-select": "^5.10.2",
@@ -31,7 +33,6 @@
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/inputmask": "^5.0.7",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -196,6 +197,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
@@ -1266,6 +1273,180 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@react-pdf/fns": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.3.tgz",
"integrity": "sha512-N1qQDZr6phXYQOp033Hvm2nkUkx2LkszjGPbmRavs9VOYzi4sp31MaccMKptL24ii6UhBh/z9yPUhnuNe/qHwA==",
"license": "MIT",
"dependencies": {
"@react-pdf/pdfkit": "^4.0.4",
"@react-pdf/types": "^2.9.1",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.3.tgz",
"integrity": "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.1.tgz",
"integrity": "sha512-GVzdlWoZWldRDzlWj3SttRXmVDxg7YfraAohwy+o9gb9hrbDJaaAV6jV3pc630Evd3K46OAzk8EFu8EgPDuVuA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/image": "^3.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.1",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.4.tgz",
"integrity": "sha512-/nITLggsPlB66bVLnm0X7MNdKQxXelLGZG6zB5acF5cCgkFwmXHnLNyxYOUD4GMOMg1HOPShXDKWrwk2ZeHsvw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"license": "MIT",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.4.tgz",
"integrity": "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.1.tgz",
"integrity": "sha512-v1WAaAhQShQZGcBxfjkEThGCHVH9CSuitrZ1bIOLvB5iBKM14abYK5D6djKhWCwF6FTzYeT2WRjRMVgze/ND2A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.1",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.1.tgz",
"integrity": "sha512-dPKHiwGTaOsKqNWCHPYYrx8CDfAGsUnV4tvRsEu0VPGxuot1AOq/M+YgfN/Pb+MeXCTe2/lv6NvA8haUtj3tsA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/font": "^4.0.3",
"@react-pdf/layout": "^4.4.1",
"@react-pdf/pdfkit": "^4.0.4",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.4",
"@react-pdf/render": "^4.3.1",
"@react-pdf/types": "^2.9.1",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.1.tgz",
"integrity": "sha512-Iyw0A3wRIeQLN4EkaKf8yF9MvdMxiZ8JjoyzLzDHSxnKYoOA4UGu84veCb8dT9N8MxY5x7a0BUv/avTe586Plg==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/types": "^2.9.1",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.0.0.tgz",
"integrity": "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.1.tgz",
"integrity": "sha512-5GoCgG0G5NMgpPuHbKG2xcVRQt7+E5pg3IyzVIIozKG3nLcnsXW4zy25vG1ZBQA0jmo39q34au/sOnL/0d1A4w==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.1"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1639,13 +1820,6 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/inputmask": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz",
"integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2261,6 +2435,12 @@
"win32" "win32"
] ]
}, },
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2517,6 +2697,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2586,6 +2775,35 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2610,6 +2828,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -2711,6 +2947,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2737,9 +2982,18 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2796,6 +3050,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -2873,6 +3133,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2970,6 +3246,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3014,6 +3296,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -3647,11 +3935,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
@@ -3721,6 +4017,18 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -3798,6 +4106,23 @@
} }
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4151,6 +4476,21 @@
"react-is": "^16.7.0" "react-is": "^16.7.0"
} }
}, },
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/husky": { "node_modules/husky": {
"version": "9.1.7", "version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -4167,6 +4507,12 @@
"url": "https://github.com/sponsors/typicode" "url": "https://github.com/sponsors/typicode"
} }
}, },
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
"license": "ISC"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4203,11 +4549,11 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inputmask": { "node_modules/inherits": {
"version": "5.0.9", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "MIT" "license": "ISC"
}, },
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
@@ -4585,6 +4931,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -4663,6 +5015,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -4680,9 +5041,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5065,6 +5426,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -5137,6 +5517,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -5347,6 +5733,15 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5537,6 +5932,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5567,6 +5968,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5660,6 +6067,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5719,6 +6132,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5749,6 +6171,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-day-picker": {
"version": "9.11.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@@ -5761,6 +6204,23 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/react-dropzone": {
"version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-fast-compare": { "node_modules/react-fast-compare": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
@@ -5887,6 +6347,15 @@
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5926,6 +6395,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -5981,6 +6456,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6226,6 +6721,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -6265,6 +6775,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6455,6 +6974,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/swr": { "node_modules/swr": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
@@ -6505,6 +7030,12 @@
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tiny-warning": { "node_modules/tiny-warning": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@@ -6753,6 +7284,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -6833,6 +7390,26 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6970,6 +7547,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/yup": { "node_modules/yup": {
"version": "1.7.1", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
+3 -2
View File
@@ -11,16 +11,18 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.1",
"@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"inputmask": "^5.0.9",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-dropzone": "^14.3.8",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
"react-select": "^5.10.2", "react-select": "^5.10.2",
@@ -34,7 +36,6 @@
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/inputmask": "^5.0.7",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
+11
View File
@@ -0,0 +1,11 @@
import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm';
const AddExpense = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<ExpenseRequestForm />
</div>
);
};
export default AddExpense;
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ExpenseEditPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const expenseId = searchParams.get('expenseId');
const { data: expense, isLoading: isLoadingExpense } = useSWR(
expenseId,
(id: number) => ExpenseApi.getSingle(id)
);
if (!expenseId) {
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 (!isLoadingExpense && (!expense || isResponseError(expense))) {
router.replace('/404');
return;
}
const isExpenseRejectedOrApproved =
!isLoadingExpense &&
isResponseSuccess(expense) &&
(expense.data.approval.action === 'REJECTED' ||
expense.data.approval.step_number === 5);
if (isExpenseRejectedOrApproved) {
router.back();
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingExpense && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingExpense && isResponseSuccess(expense) && (
<ExpenseRequestForm type='edit' initialValues={expense.data} />
)}
</div>
);
};
export default ExpenseEditPage;
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ExpenseDetail from '@/components/pages/expense/ExpenseDetail';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ExpenseDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const expenseId = searchParams.get('expenseId');
const { data: expense, isLoading: isLoadingExpense } = useSWR(
expenseId,
(id: number) => ExpenseApi.getSingle(id)
);
if (!expenseId) {
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 (!isLoadingExpense && (!expense || isResponseError(expense))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingExpense && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingExpense && isResponseSuccess(expense) && (
<ExpenseDetail initialValues={expense.data} />
)}
</div>
);
};
export default ExpenseDetailPage;
+11
View File
@@ -0,0 +1,11 @@
import ExpensesTable from '@/components/pages/expense/ExpensesTable';
const Expense = () => {
return (
<section className='w-full p-4'>
<ExpensesTable />
</section>
);
};
export default Expense;
+5
View File
@@ -48,3 +48,8 @@
html { html {
scrollbar-gutter: initial; scrollbar-gutter: initial;
} }
.react-select__menu-portal {
position: relative;
z-index: 99999 !important;
}
@@ -0,0 +1,54 @@
'use client';
import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const EditMarketingDelivery = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('marketingId');
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
);
if (!soId) {
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 (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<MarketingForm
formType='add_deliver'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)}
</div>
);
};
export default EditMarketingDelivery;
@@ -0,0 +1,11 @@
import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
const AddSalesOrder = () => {
return (
<div className='size-full p-4'>
<MarketingForm formType='add' />
</div>
);
};
export default AddSalesOrder;
@@ -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,62 @@
'use client';
import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const EditMarketingDelivery = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('marketingId');
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
);
if (!soId) {
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 (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
if (
isResponseSuccess(marketing) &&
marketing.data.latest_approval.step_number != 3
) {
toast.error('Data Marketing perlu dilakukan approval terlebih dahulu!');
router.back();
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<MarketingForm
formType='edit_deliver'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)}
</div>
);
};
export default EditMarketingDelivery;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+49
View File
@@ -0,0 +1,49 @@
'use client';
import MarketingDetail from '@/components/pages/marketing/detail/MarketingDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const DetailMarketing = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('marketingId');
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(soId, (id: number) => MarketingApi.getSingle(id));
if (!soId) {
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 (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<MarketingDetail
initialValues={marketing.data}
refresh={refreshMarketing}
/>
)}
</div>
);
};
export default DetailMarketing;
@@ -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,52 @@
'use client';
import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const EditSalesOrder = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('marketingId');
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
);
if (!soId) {
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 (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<MarketingForm
formType='edit'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)}
</div>
);
};
export default EditSalesOrder;
+10
View File
@@ -0,0 +1,10 @@
import MarketingTable from '@/components/pages/marketing/MarketingTable';
const Marketing = () => {
return (
<div className='w-full p-4'>
<MarketingTable />
</div>
);
};
export default Marketing;
-270
View File
@@ -1,270 +0,0 @@
'use client';
import Button from '@/components/Button';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import useSWR from 'swr';
const AddChickin = () => {
const router = useRouter();
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
// Tables Props
const { state: tableFilterState } = useTableFilter({
initial: { search: '' },
paramMap: { page: 'page', pageSize: 'limit' },
});
// States
const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>(
undefined
);
const [projectFlockKandang, setProjectFlockKandang] =
useState<BaseApiResponse<ProjectFlockKandang>>();
const [isLoadingProjectFlockKandang, setIsLoadingProjectFlockKandang] =
useState(false);
const [searchProjectFlock, setSearchProjectFlock] = useState('');
// Fetch Data
const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR(
projectFlockId,
(id: number) => ProjectFlockApi.getSingle(id)
);
const { data: listProjectFlock, isLoading: isLoadingListProjectFlock } =
useSWR(
`${ProjectFlockApi.basePath}?${new URLSearchParams({
search: searchProjectFlock,
}).toString()}`,
ProjectFlockApi.getAllFetcher
);
const getProjectFlockKandangUrl = `/kandangs/lookup`;
// Mapping Options
const options = isResponseSuccess(listProjectFlock)
? listProjectFlock?.data.map((projectFlock) => {
return {
value: projectFlock.id,
label: `${projectFlock?.flock?.name} - ${projectFlock?.category} - Periode ${projectFlock.period}`,
};
})
: [];
const chickinModal = useModal();
const alertModal = useModal();
if (!projectFlockId) {
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))
) {
router.replace('/404');
return;
}
// Handle Function
const handleChickinClick = async (kandang: Kandang) => {
setIsLoadingProjectFlockKandang(true);
setSelectedKandang(kandang);
const ProjectFlockKandangRes = await ProjectFlockApi.customRequest<
BaseApiResponse<ProjectFlockKandang>,
'GET'
>(getProjectFlockKandangUrl, {
method: 'GET',
params: {
project_flock_id: projectFlockId ?? 0,
kandang_id: kandang.id,
},
});
if (isResponseSuccess(ProjectFlockKandangRes)) {
setProjectFlockKandang(ProjectFlockKandangRes);
setIsLoadingProjectFlockKandang(false);
if (
ProjectFlockKandangRes.data.available_quantity &&
ProjectFlockKandangRes.data.available_quantity > 0
) {
chickinModal.openModal();
} else {
alertModal.openModal();
}
}
};
const handleAfterSubmit = () => {
chickinModal.closeModal();
router.push('/production/chickin');
};
return (
<>
{isResponseSuccess(projectFlock) && (
<>
<section className='w-full p-4'>
<header className='flex flex-col gap-4'>
<Button
href='/production/project-flock'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<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'>
<SelectInput
required
isSearchable
label='Project Flock'
options={options}
isLoading={isLoadingListProjectFlock}
value={{
label: `${projectFlock.data?.flock?.name} - ${projectFlock.data?.category} - Periode ${projectFlock.data?.period}`,
value: projectFlock.data?.id,
}}
onChange={(val) =>
router.push(
`/production/chickin/add?projectFlockId=${
(val as OptionType | null)?.value
}`
)
}
onInputChange={(val) => {
setSearchProjectFlock(val);
}}
/>
</div>
</div>
</header>
<Table<Kandang>
data={projectFlock.data?.kandangs}
columns={[
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama Kandang',
},
{
header: 'Aksi',
cell: (props) => {
return (
<>
<Button
color='success'
variant='outline'
onClick={() => {
handleChickinClick(props.row.original);
}}
disabled={isLoadingProjectFlockKandang}
>
<Icon
icon='mdi:home-import-outline'
width={24}
height={24}
/>
Chickin
</Button>
</>
);
},
},
]}
page={undefined}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(projectFlock) &&
projectFlock.data?.kandangs?.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',
}}
/>
</section>
<Modal ref={chickinModal.ref}>
<div className='flex flex-row justify-between items-center'>
<h1 className='text-xl font-semibold text-center mb-6'>
Chickin Kandang - {selectedKandang?.name}
</h1>
<Button
color='error'
variant='link'
onClick={chickinModal.closeModal}
>
<Icon
className='text-black'
icon='uil:times'
width={24}
height={24}
/>
</Button>
</div>
{isResponseSuccess(projectFlockKandang) &&
!isLoadingProjectFlockKandang && (
<ChickinForm
initialValues={{
project_flock_kandang: projectFlockKandang.data,
created_user: projectFlock.data?.created_user,
created_at: projectFlock.data?.created_at,
updated_at: projectFlock.data?.updated_at,
approval: projectFlock.data?.approval,
}}
afterSubmit={handleAfterSubmit}
/>
)}
</Modal>
<ConfirmationModal
ref={alertModal.ref}
type='info'
text={`Persediaan Day Old Chick pada kandang (${selectedKandang?.name}) belum ada, mohon isi terlebih dahulu di bagian Persediaan!`}
secondaryButton={undefined}
primaryButton={{
text: 'Ya',
color: 'info',
onClick: () => {
alertModal.closeModal();
},
}}
/>
</>
)}
</>
);
};
export default AddChickin;
-351
View File
@@ -1,351 +0,0 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ChickinApi } from '@/services/api/production';
import { BaseApiResponse } from '@/types/api/api-general';
import {
Chickin,
ChickinApprovalPayload,
} from '@/types/api/production/chickin';
import { Icon } from '@iconify/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
/**
* TODO: Refactor code - pindahin detail ke reuseable component
* setelah implement approval and reject
*/
const DetailChickin = () => {
const router = useRouter();
const searchParams = useSearchParams();
const chickinId = searchParams.get('chickinId');
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const confirmModal = useModal();
const deleteModal = useModal();
const chickinModal = useModal();
const {
data: chickin,
isLoading,
mutate: refreshChickin,
} = useSWR(chickinId, (id: number) => ChickinApi.getSingle(id));
const [isApprovedDisabled, setIsApprovedDisabled] = useState(
// chickin.data?.approval.step_number == 1 ? false : true
true
);
const [isRejectedDisabled, setIsRejectedDisabled] =
useState(!isApprovedDisabled);
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
!isApprovedDisabled ? 'APPROVED' : 'REJECTED'
);
if (!chickinId) {
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 (!isLoading && (!chickin || isResponseError(chickin))) {
router.replace('/404');
return;
}
if (!isResponseSuccess(chickin)) {
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
const confirmationModalClickHandler = async ({
action = 'APPROVED',
}: {
action: 'APPROVED' | 'REJECTED';
}) => {
if (chickin?.data.id === undefined) return;
setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.customRequest<
BaseApiResponse<Chickin>,
ChickinApprovalPayload
>(`/approvals`, {
method: 'POST',
payload: {
action: action,
approvable_ids: [chickin.data.id],
},
});
if (isResponseSuccess(approveChickinRes)) {
if (refreshChickin) {
await refreshChickin();
}
toast.success(approveChickinRes.message as string);
}
if (isResponseError(approveChickinRes)) {
toast.error(approveChickinRes?.message as string);
}
confirmModal.closeModal();
setIsApproveLoading(false);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteProjectFlockRes = await ChickinApi.delete(
chickin.data?.id as number
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push('/production/chickin');
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
deleteModal.closeModal();
setIsDeleteLoading(false);
};
return (
<>
<div className='w-full p-4 flex flex-col justify-center gap-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(chickin) && (
<>
{/* <div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
variant='outline'
color='success'
onClick={(() => {
if (chickin?.data.id) {
setApprovalAction('APPROVED');
confirmModal.openModal();
}
})}
disabled={!chickin?.data.id || isApprovedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
<Button
variant='outline'
color='error'
onClick={() => {
if (chickin?.data.id) {
setApprovalAction('REJECTED');
confirmModal.openModal();
}
}}
disabled={!chickin?.data.id || isRejectedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</div> */}
<Card
title='Informasi Umum'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.flock
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Area</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.area
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kategori</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.category}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Lokasi</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.location
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Periode</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.period}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kandang</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
</div>
</Card>
<Card
title='Detail Chickin'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock Kandang</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.flock
.name
}{' '}
- {chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Tanggal Chickin</div>
<div className='text-sm'>
{chickin.data.chick_in_date
? new Date(chickin.data.chick_in_date).toLocaleDateString(
'id-ID'
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Jumlah (Ekor)</div>
<div className='text-sm'>
{chickin.data.quantity?.toLocaleString('id-ID')}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Catatan</div>
<div className='text-sm'>{chickin.data.note}</div>
</div>
</div>
</Card>
<div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
color='error'
onClick={() => {
deleteModal.openModal();
}}
>
<Icon icon='mdi:times' width={24} height={24} />
Delete
</Button>
<Button
color='warning'
onClick={() => {
chickinModal.openModal();
}}
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit
</Button>
</div>
</>
)}
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${chickin?.data.project_flock_kandang?.project_flock.flock.name} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<Modal ref={chickinModal.ref}>
<div className='flex flex-row justify-between items-center'>
<h1 className='text-xl font-semibold text-center mb-6'>
Chickin Kandang -{' '}
{chickin?.data?.project_flock_kandang &&
chickin?.data?.project_flock_kandang.kandang?.name}
</h1>
<Button
color='error'
variant='link'
onClick={chickinModal.closeModal}
>
<Icon
className='text-black'
icon='uil:times'
width={24}
height={24}
/>
</Button>
</div>
<ChickinForm
initialValues={chickin?.data}
formType='edit'
afterSubmit={() => {
refreshChickin();
chickinModal.closeModal();
}}
/>
</Modal>
<ConfirmationModal
ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject'
} chickin berikut? (${
chickin?.data.project_flock_kandang?.project_flock.flock.name
} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: () => {
confirmationModalClickHandler({
action: approvalAction,
});
},
}}
/>
</>
);
};
export default DetailChickin;
@@ -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,60 @@
'use client';
import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm';
import { isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
export default function AddChickinKandang() {
const searchParams = useSearchParams();
const projectFlockKandangId = searchParams.get('projectFlockKandangId');
const projectFlockId = searchParams.get('projectFlockId');
const router = useRouter();
const {
data: projectFlockKandang,
isLoading: isLoading,
mutate: refreshProjectFlockKandang,
} = useSWR(
`get-single-project-flock-kandang/${projectFlockKandangId}`,
async () =>
ProjectFlockKandangApi.getSingle(
parseInt(projectFlockKandangId as string)
)
);
if (!projectFlockKandangId) {
router.push(`/production/chickin/add?projectFlockId=${projectFlockId}`);
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && !projectFlockKandang) {
router.replace('/404');
return;
}
const handleAfterSubmit = () => {
refreshProjectFlockKandang();
};
return (
<>
<section className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading &&
isResponseSuccess(projectFlockKandang) &&
projectFlockId && (
<ChickinForm
initialValues={projectFlockKandang.data}
afterSubmit={handleAfterSubmit}
/>
)}
</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,20 @@
'use client';
import { FormHeader } from '@/components/helper/form/FormHeader';
import ProjectFlockChickinDetail from '@/components/pages/production/project-flock/chickin/ProjectFlockChickinDetail';
import { useSearchParams } from 'next/navigation';
const AddChickin = () => {
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
return (
<>
<section className='w-full p-4'>
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
</section>
</>
);
};
export default AddChickin;
@@ -2,7 +2,7 @@
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'; 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';
@@ -12,10 +12,11 @@ const ProjectFlockEdit = () => {
const projectFlockId = searchParams.get('projectFlockId'); const projectFlockId = searchParams.get('projectFlockId');
const { data: projectFlock, isLoading: isLoadingCostumer } = useSWR( const {
projectFlockId, data: projectFlock,
(id: number) => ProjectFlockApi.getSingle(id) isLoading: isLoadingProjectFlock,
); mutate: refreshProjectFlocks,
} = useSWR(projectFlockId, (id: number) => ProjectFlockApi.getSingle(id));
if (!projectFlockId) { if (!projectFlockId) {
router.back(); router.back();
@@ -27,17 +28,20 @@ const ProjectFlockEdit = () => {
); );
} }
if (!isLoadingCostumer && (!projectFlock || isResponseError(projectFlock))) { if (
!isLoadingProjectFlock &&
(!projectFlock || isResponseError(projectFlock))
) {
router.replace('/404'); router.replace('/404');
return; return;
} }
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-col justify-center'>
{isLoadingCostumer && ( {isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
{!isLoadingCostumer && isResponseSuccess(projectFlock) && ( {!isLoadingProjectFlock && isResponseSuccess(projectFlock) && (
<ProjectFlockForm formType='edit' initialValues={projectFlock.data} /> <ProjectFlockForm formType='edit' initialValues={projectFlock.data} />
)} )}
</div> </div>
@@ -2,7 +2,7 @@
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'; 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';
@@ -37,11 +37,11 @@ const ProjectFlockDetail = () => {
} }
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-col justify-center'>
{isLoadingProjectFlock && ( {isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
{!isLoadingProjectFlock && isResponseSuccess(projectFlock) && ( {isResponseSuccess(projectFlock) && (
<ProjectFlockForm <ProjectFlockForm
formType='detail' formType='detail'
initialValues={projectFlock.data} initialValues={projectFlock.data}
@@ -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,49 @@
'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;
@@ -0,0 +1,53 @@
'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;
@@ -0,0 +1,52 @@
'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;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -8,91 +8,6 @@ import TransferToLayingForm from '@/components/pages/production/transfer-to-layi
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
// TODO: delete dummy data
const DUMMY_TRANSFER_TO_LAYING_EDIT: TransferToLaying = {
id: 1,
transfer_date: '2025-10-14',
flock_source: {
id: 1,
name: 'Flock asal test',
},
flock_destination: {
id: 2,
name: 'Flock tujuan destination',
},
quantity: 10,
kandangs: [
{
kandang: {
id: 1,
name: 'Kandang test',
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 8,
},
{
kandang: {
id: 1,
name: 'Kandang test 2',
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 2,
},
],
reason: 'Test alasan',
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
};
const TransferToLayingEdit = () => { const TransferToLayingEdit = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -114,33 +29,33 @@ const TransferToLayingEdit = () => {
); );
} }
// TODO: remove dummy data and integrate with real API
if ( if (
!isLoadingTransferToLaying && !isLoadingTransferToLaying &&
(!transferToLaying || (!transferToLaying || isResponseError(transferToLaying))
(isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_EDIT))
) { ) {
router.replace('/404'); router.replace('/404');
return; return;
} }
if (
isResponseSuccess(transferToLaying) &&
transferToLaying.data.approval.step_number === 2
) {
router.replace('/production/transfer-to-laying');
return;
}
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-row justify-center'>
{isLoadingTransferToLaying && ( {isLoadingTransferToLaying && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
{/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && (
<TransferToLayingForm
type='detail'
initialValues={transferToLaying.data}
/>
)} */}
{/* TODO: remove this dummy data and integrate to real API */}
<TransferToLayingForm <TransferToLayingForm
type='edit' type='edit'
initialValues={DUMMY_TRANSFER_TO_LAYING_EDIT} initialValues={transferToLaying.data}
/> />
)}
</div> </div>
); );
}; };
@@ -8,91 +8,6 @@ import TransferToLayingForm from '@/components/pages/production/transfer-to-layi
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
// TODO: delete dummy data
const DUMMY_TRANSFER_TO_LAYING_DETAIL: TransferToLaying = {
id: 1,
transfer_date: '2025-10-14',
flock_source: {
id: 1,
name: 'Flock asal test',
},
flock_destination: {
id: 2,
name: 'Flock tujuan destination',
},
quantity: 10,
kandangs: [
{
kandang: {
id: 1,
name: 'Kandang test',
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 8,
},
{
kandang: {
id: 1,
name: 'Kandang test 2',
status: 'ACTIVE',
location: {
id: 1,
name: 'test location',
address: 'test address 1',
area: { id: 1, name: 'test area 1' },
},
pic: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
},
quantity: 2,
},
],
reason: 'Test alasan',
created_user: {
id: 1,
id_user: 2,
email: 'test@gmail.com',
name: 'test',
},
created_at: '14-10-2025',
updated_at: '14-10-2025',
};
const TransferToLayingDetail = () => { const TransferToLayingDetail = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -114,11 +29,9 @@ const TransferToLayingDetail = () => {
); );
} }
// TODO: remove dummy data and integrate with real API
if ( if (
!isLoadingTransferToLaying && !isLoadingTransferToLaying &&
(!transferToLaying || (!transferToLaying || isResponseError(transferToLaying))
(isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_DETAIL))
) { ) {
router.replace('/404'); router.replace('/404');
return; return;
@@ -129,18 +42,13 @@ const TransferToLayingDetail = () => {
{isLoadingTransferToLaying && ( {isLoadingTransferToLaying && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
{/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && (
{!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && (
<TransferToLayingForm <TransferToLayingForm
type='detail' type='detail'
initialValues={transferToLaying.data} initialValues={transferToLaying.data}
/> />
)} */} )}
{/* TODO: remove this dummy data and integrate to real API */}
<TransferToLayingForm
type='detail'
initialValues={DUMMY_TRANSFER_TO_LAYING_DETAIL}
/>
</div> </div>
); );
}; };
+1 -1
View File
@@ -3,7 +3,7 @@ import Link from 'next/link';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
interface ButtonProps extends react.ComponentProps<'button'> { export interface ButtonProps extends react.ComponentProps<'button'> {
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active'; variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
color?: Color; color?: Color;
href?: string; href?: string;
+3 -2
View File
@@ -3,6 +3,7 @@
import { HTMLAttributes, ReactNode } from 'react'; import { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import Image from 'next/image';
export interface CardProps export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> { extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
@@ -106,7 +107,7 @@ const Card = ({
return ( return (
<div className={getCardClasses()} {...props}> <div className={getCardClasses()} {...props}>
<figure> <figure>
<img <Image
src={image} src={image}
alt={imageAlt || title || 'Card image'} alt={imageAlt || title || 'Card image'}
className={getImageClasses()} className={getImageClasses()}
@@ -127,7 +128,7 @@ const Card = ({
<div className={getCardClasses()} {...props}> <div className={getCardClasses()} {...props}>
{image && ( {image && (
<figure> <figure>
<img <Image
src={image} src={image}
alt={imageAlt || title || 'Card image'} alt={imageAlt || title || 'Card image'}
className={getImageClasses()} className={getImageClasses()}
+37 -23
View File
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react'; import {
ReactNode,
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
export const useModal = () => { export const useModal = () => {
@@ -8,31 +15,34 @@ export const useModal = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const openModal = useCallback(() => { const openModal = useCallback(() => {
if (!ref.current) return;
ref.current.show();
setOpen(true); setOpen(true);
ref.current?.showModal();
}, []); }, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
if (!ref.current) return;
ref.current.close();
setOpen(false); setOpen(false);
ref.current?.close();
}, []); }, []);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (open) { open ? closeModal() : openModal();
closeModal();
} else {
openModal();
}
}, [open, closeModal, openModal]); }, [open, closeModal, openModal]);
if (ref.current) { useEffect(() => {
ref.current.addEventListener('close', () => { const dialog = ref.current;
closeModal(); if (!dialog) return;
});
}
return { ref, open, setOpen, openModal, closeModal, toggle } as const; const handleClose = () => setOpen(false);
dialog.addEventListener('close', handleClose);
return () => {
dialog.removeEventListener('close', handleClose);
};
}, []);
return { ref, open, openModal, closeModal, toggle } as const;
}; };
interface ModalProps { interface ModalProps {
@@ -46,15 +56,19 @@ interface ModalProps {
} }
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
return ( const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
<dialog ref={ref} className={cn('modal', className?.modal)}> if (closeOnBackdrop && e.target === ref.current) {
<div className={cn('modal-box', className?.modalBox)}>{children}</div> ref.current?.close();
}
};
{closeOnBackdrop && ( return (
<form method='dialog' className='modal-backdrop'> <dialog
<button>close</button> ref={ref}
</form> className={cn('modal', className?.modal)}
)} onClick={handleBackdropClick}
>
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
</dialog> </dialog>
); );
}; };
+7
View File
@@ -13,6 +13,7 @@ import {
FilterFn, FilterFn,
SortingState, SortingState,
OnChangeFn, OnChangeFn,
Row,
} 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';
@@ -50,6 +51,7 @@ export interface TableProps<TData extends object> {
manualSorting?: boolean; manualSorting?: boolean;
rowSelection?: Record<string, boolean>; rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>; setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
} }
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -90,6 +92,7 @@ const Table = <TData extends object>({
manualSorting = false, manualSorting = false,
rowSelection, rowSelection,
setRowSelection, setRowSelection,
enableRowSelection,
}: TableProps<TData>) => { }: TableProps<TData>) => {
const isServerSideTable = const isServerSideTable =
totalItems !== undefined && totalItems !== undefined &&
@@ -150,6 +153,10 @@ const Table = <TData extends object>({
tableOptions.getRowId = (row) => (row as { id: string }).id; tableOptions.getRowId = (row) => (row as { id: string }).id;
} }
if (enableRowSelection !== undefined) {
tableOptions.enableRowSelection = enableRowSelection;
}
const table = useReactTable(tableOptions); const table = useReactTable(tableOptions);
const { setPageSize } = table; const { setPageSize } = table;
+129
View File
@@ -0,0 +1,129 @@
import { HTMLAttributes, ReactNode, useEffect, useState } from 'react';
import { cn } from '@/lib/helper';
export interface TabItem {
id: string;
label: ReactNode;
content?: ReactNode;
disabled?: boolean;
}
export interface TabsProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
tabs: TabItem[];
variant?: 'bordered' | 'lifted' | 'boxed';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
placement?: 'top' | 'bottom';
/** Tab yang aktif secara default (uncontrolled mode) */
defaultActiveId?: string;
/** Tab yang aktif (controlled mode, dikontrol parent) */
activeTabId?: string;
className?:
| string
| {
wrapper?: string;
tab?: string;
content?: string;
};
onTabChange?: (tabId: string) => void;
}
const Tabs = ({
tabs,
variant,
size = 'md',
placement = 'top',
defaultActiveId,
activeTabId: controlledActiveId,
className,
onTabChange,
...props
}: TabsProps) => {
// State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset
const [uncontrolledActiveId, setUncontrolledActiveId] = useState(
defaultActiveId || tabs[0]?.id || ''
);
const isControlled = controlledActiveId !== undefined;
const activeTabId = isControlled ? controlledActiveId : uncontrolledActiveId;
const handleTabChange = (tabId: string) => {
if (tabId === activeTabId) return;
if (!isControlled) setUncontrolledActiveId(tabId);
onTabChange?.(tabId);
};
const { wrapper: wrapperClassName, tab: tabClassName } =
typeof className === 'object'
? className
: { wrapper: className, tab: undefined };
const getTabsClasses = () => {
const variantClasses: Record<string, string> = {
bordered: 'tabs-bordered',
lifted: 'tabs-lift',
boxed: 'tabs-box',
};
const sizeClasses: Record<string, string> = {
xs: 'tabs-xs',
sm: 'tabs-sm',
md: '',
lg: 'tabs-lg',
xl: 'tabs-xl',
};
const placementClasses: Record<string, string> = {
top: '',
bottom: 'tabs-bottom',
};
return cn(
'tabs',
variant && variantClasses[variant],
sizeClasses[size],
placementClasses[placement],
wrapperClassName
);
};
const getTabClasses = (isActive: boolean, isDisabled?: boolean) =>
cn(
'tab',
{
'tab-active': isActive,
'tab-disabled': isDisabled,
},
tabClassName
);
const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content;
return (
<div
{...props}
className={cn(
'w-full',
typeof className === 'string' ? className : undefined
)}
>
<div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled }) => (
<button
key={id}
role='tab'
className={getTabClasses(id === activeTabId, disabled)}
onClick={() => !disabled && handleTabChange(id)}
disabled={disabled}
>
{label}
</button>
))}
</div>
{activeContent && <div className='mt-4'>{activeContent}</div>}
</div>
);
};
export default Tabs;
+53 -1
View File
@@ -9,6 +9,11 @@ interface FormActionsProps<T> {
editUrl?: string; editUrl?: string;
onDelete?: () => void; onDelete?: () => void;
disableSubmit?: boolean; disableSubmit?: boolean;
onApprove?: () => void;
onReject?: () => void;
isApproveLoading?: boolean;
isRejectLoading?: boolean;
showApproveReject?: boolean;
} }
export const FormActions = <T,>({ export const FormActions = <T,>({
@@ -17,11 +22,17 @@ export const FormActions = <T,>({
editUrl, editUrl,
onDelete, onDelete,
disableSubmit = false, disableSubmit = false,
onApprove,
onReject,
isApproveLoading = false,
isRejectLoading = false,
showApproveReject = false,
}: FormActionsProps<T>) => { }: FormActionsProps<T>) => {
return ( return (
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && onDelete && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
{onDelete && (
<Button <Button
type='button' type='button'
color='error' color='error'
@@ -36,6 +47,7 @@ export const FormActions = <T,>({
/> />
Delete Delete
</Button> </Button>
)}
{type !== 'edit' && editUrl && ( {type !== 'edit' && editUrl && (
<Button <Button
type='button' type='button'
@@ -52,6 +64,46 @@ export const FormActions = <T,>({
Edit Edit
</Button> </Button>
)} )}
{type === 'detail' &&
showApproveReject &&
(onApprove || onReject) && (
<>
{onApprove && (
<Button
type='button'
color='success'
onClick={onApprove}
className='px-4'
isLoading={isApproveLoading}
>
<Icon
icon='material-symbols:check-circle-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Approve
</Button>
)}
{onReject && (
<Button
type='button'
color='error'
onClick={onReject}
className='px-4'
isLoading={isRejectLoading}
>
<Icon
icon='material-symbols:cancel-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Reject
</Button>
)}
</>
)}
</div> </div>
)} )}
{type !== 'detail' && ( {type !== 'detail' && (
+17 -4
View File
@@ -2,15 +2,27 @@ import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
interface FormHeaderProps { interface FormHeaderProps {
type: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
title: string; title: string;
backUrl: string; backUrl?: string;
onBackClick?: () => void;
} }
export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => { export const FormHeader = ({
type,
title,
backUrl,
onBackClick,
}: FormHeaderProps) => {
return ( return (
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button href={backUrl} variant='link' className='w-fit p-0 text-primary'> <Button
type='button'
href={!onBackClick ? backUrl : undefined}
onClick={onBackClick}
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} /> <Icon icon='uil:arrow-left' width={24} height={24} />
Kembali Kembali
</Button> </Button>
@@ -18,6 +30,7 @@ export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => {
{type === 'add' && `Tambah ${title}`} {type === 'add' && `Tambah ${title}`}
{type === 'edit' && `Edit ${title}`} {type === 'edit' && `Edit ${title}`}
{type === 'detail' && `Detail ${title}`} {type === 'detail' && `Detail ${title}`}
{!type && title}
</h1> </h1>
</header> </header>
); );
+243 -39
View File
@@ -1,14 +1,23 @@
'use client'; 'use client';
import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; import {
ChangeEventHandler,
import { cn } from '@/lib/helper'; FocusEventHandler,
useEffect,
useState,
} from 'react';
import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '@/components/Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
export interface DateInputProps { export interface DateInputProps {
label?: string; label?: string;
bottomLabel?: string; bottomLabel?: string;
name: string; name: string;
value?: string; value?: string | { from?: string; to?: string };
placeholder?: string; placeholder?: string;
min?: string; min?: string;
max?: string; max?: string;
@@ -24,9 +33,8 @@ export interface DateInputProps {
readOnly?: boolean; readOnly?: boolean;
required?: boolean; required?: boolean;
isLoading?: boolean; isLoading?: boolean;
isRange?: boolean;
errorMessage?: string; errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
} }
@@ -36,22 +44,147 @@ const DateInput = ({
bottomLabel, bottomLabel,
name, name,
value, value,
placeholder, placeholder = 'dd/mm/yyyy',
min, min,
max, max,
className, className,
isError, isError: externalError,
isValid, isValid: externalValid,
errorMessage, errorMessage: externalErrorMessage,
startAdornment,
endAdornment,
disabled = false, disabled = false,
required = false, required = false,
onChange, onChange,
onBlur, onBlur,
readOnly = false, readOnly = false,
isLoading = false, isLoading = false,
isRange = false,
}: DateInputProps) => { }: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null);
const [selected, setSelected] = useState<Date | undefined>();
const [selectedRange, setSelectedRange] = useState<{
from?: Date;
to?: Date;
}>({});
const [displayValue, setDisplayValue] = useState<string>('');
const minDate = min
? new Date(min.split('/').reverse().join('-'))
: undefined;
const maxDate = max
? new Date(max.split('/').reverse().join('-'))
: undefined;
const calendarModal = useModal();
// --- Sync value props ---
useEffect(() => {
if (!value) {
setDisplayValue('');
return;
}
if (isRange && typeof value === 'object') {
const from = value.from ? new Date(value.from) : undefined;
const to = value.to ? new Date(value.to) : undefined;
setSelectedRange({ from, to });
setDisplayValue(
`${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${
to ? '- ' + formatDate(to, 'DD/MM/YYYY') : ''
}`
);
} else if (typeof value === 'string') {
const iso = value.includes('/')
? value.split('/').reverse().join('-')
: value;
const date = new Date(iso);
setSelected(date);
setDisplayValue(formatDate(iso, 'DD/MM/YYYY'));
}
}, [value, isRange]);
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault();
if (!disabled && !readOnly) calendarModal.openModal();
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
};
const handleSelectSingle = (selectedDate?: Date) => {
if (!selectedDate) return;
if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return;
}
if (maxDate && selectedDate > maxDate) {
setInternalError(`Tanggal tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
setSelected(selectedDate);
const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY');
const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD');
setDisplayValue(formattedDisplay);
const syntheticEvent = {
target: { name, value: formattedISO },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
if (!range) return;
setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : '';
setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`);
// Jika kedua tanggal sudah terpilih
if (range.from && range.to) {
if (minDate && range.from < minDate) {
setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`);
return;
}
if (maxDate && range.to > maxDate) {
setInternalError(`Tanggal akhir tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
const syntheticEvent = {
target: {
name,
value: {
from: formatDate(range.from, 'YYYY-MM-DD'),
to: formatDate(range.to, 'YYYY-MM-DD'),
},
},
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
}
};
const handleResetDate = () => {
setSelected(undefined);
setSelectedRange({});
setDisplayValue('');
const syntheticEvent = {
target: { name, value: isRange ? { from: '', to: '' } : '' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSaveDate = () => {
if (internalError) return;
calendarModal.closeModal();
};
const finalIsError = externalError || !!internalError;
const finalErrorMessage = internalError || externalErrorMessage;
return ( return (
<div <div
className={cn( className={cn(
@@ -64,65 +197,136 @@ const DateInput = ({
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full text-sm font-normal leading-5',
{ { 'text-error': finalIsError },
'text-error': isError,
},
className?.label className?.label
)} )}
> >
{label} {label}
{required && ( {required && (
<> <span className='text-error' title='required'>
{' '} {' '}
<span className='tooltip tooltip-error' data-tip='required'> *
<span className='text-error'>*</span>
</span> </span>
</>
)} )}
</label> </label>
)} )}
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200 flex items-center', 'input h-12 bg-inherit px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border',
{ {
'border-error': isError, 'border-error': finalIsError,
'border-success!': isValid, 'border-success': externalValid && !finalIsError,
}, },
className?.inputWrapper className?.inputWrapper
)} )}
> >
{startAdornment && startAdornment}
<input <input
type='date' type='text'
id={name} id={name}
name={name} name={name}
placeholder={placeholder} placeholder={isRange ? 'dd/mm/yyyy - dd/mm/yyyy' : placeholder}
value={value} value={displayValue}
onChange={onChange} onBlur={handleBlur}
onBlur={onBlur} onClick={handleClick}
min={min}
max={max}
disabled={disabled} disabled={disabled}
className={cn('grow bg-transparent cursor-pointer', className?.input)} readOnly // ✅ tidak bisa diketik manual
readOnly={readOnly} className={cn(
'grow bg-transparent cursor-pointer focus:outline-none',
className?.input
)}
/> />
{(isLoading || endAdornment) && ( {isLoading && (
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />} <span className='loading loading-spinner' />
{endAdornment && endAdornment}
</div> </div>
)} )}
<Icon
icon='uil:calendar'
width={24}
height={24}
className='cursor-pointer text-dark'
onClick={(e) =>
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
}
/>
</div> </div>
{!isError && bottomLabel && ( {!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)} )}
{isError && errorMessage && ( {finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p> <p className='w-full text-sm text-error'>{finalErrorMessage}</p>
)} )}
<Modal
ref={calendarModal.ref}
className={{
modal: 'rounded',
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`,
}}
closeOnBackdrop
>
{isRange ? (
<DayPicker
required={required}
mode='range'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selectedRange.from ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selectedRange as DateRange}
onSelect={handleSelectRange}
footer={<div className='text-center mt-3'>{displayValue}</div>}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
) : (
<DayPicker
required={required}
mode='single'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selected ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selected}
onSelect={handleSelectSingle}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
)}
<div className='mt-auto flex flex-col gap-2'>
{isRange && (
<small className='text-secondary'>
Tekan dua kali untuk memilih tanggal awal
</small>
)}
<div className='flex h-full justify-end items-end gap-2'>
<Button type='button' color='warning' onClick={handleResetDate}>
Reset
</Button>
{isRange && (
<Button type='button' onClick={handleSaveDate}>
Simpan
</Button>
)}
</div>
</div>
</Modal>
</div> </div>
); );
}; };
+194
View File
@@ -0,0 +1,194 @@
import { useEffect } from 'react';
import { useDropzone, type Accept } from 'react-dropzone';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { cn } from '@/lib/helper';
interface DropFileInputProps {
name: string;
label?: string;
bottomLabel?: string;
caption?: string;
values?: File[];
accept?: Accept;
required?: boolean;
maxFiles?: number; // defaults to 1
maxSize?: number; // defaults to 2097152 (2 MB)
isError?: boolean;
errorMessage?: string;
disabled?: boolean;
onChange?: (files: File[]) => void;
onDelete?: (index: number) => void;
className?: {
wrapper?: string;
inputContainer?: string;
label?: string;
inputWrapper?: string;
caption?: string;
bottomLabel?: string;
errorMessage?: string;
fileItemContainer?: string;
};
}
const DropFileInput: React.FC<DropFileInputProps> = ({
name,
label,
bottomLabel,
caption = 'Seret atau Pilih Dokumen',
values,
accept,
required,
maxFiles = Infinity,
maxSize,
isError,
errorMessage,
disabled,
onChange,
onDelete,
className,
}) => {
const isDisabled =
Boolean(values && maxFiles && values.length >= maxFiles) || disabled;
const {
acceptedFiles,
getRootProps,
getInputProps,
isFocused,
isDragAccept,
isDragReject,
} = useDropzone({
maxSize,
maxFiles,
accept: accept,
disabled: isDisabled,
});
useEffect(() => {
if (values && maxFiles && values.length <= maxFiles) {
onChange?.([...values, ...acceptedFiles]);
}
}, [acceptedFiles]);
return (
<div className={cn('w-full', className?.wrapper)}>
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.inputContainer
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'>*</span>
</span>
</>
)}
</label>
)}
<div
{...getRootProps({
'aria-disabled': isDisabled,
className: cn(
'dropzone w-full px-4 py-2 border border-dashed border-gray-300 rounded cursor-pointer transition-all',
'hover:border-primary hover:bg-primary/10',
{
'border-success bg-success/10': isDragAccept,
'border-error bg-error/10': isDragReject || isError,
'border-primary bg-primary/10': isFocused,
'bg-gray-200/20 cursor-not-allowed': isDisabled,
},
className?.inputWrapper
),
})}
>
<input
{...getInputProps({
id: name,
name,
disabled: isDisabled,
'aria-disabled': isDisabled,
})}
/>
{caption && (
<p className={cn('text-gray-500 text-sm', className?.caption)}>
{caption}
</p>
)}
</div>
{!isError && bottomLabel && (
<p
className={cn('w-full text-sm opacity-60', className?.bottomLabel)}
>
{bottomLabel}
</p>
)}
{isError && (
<p
className={cn('w-full text-sm text-error', className?.errorMessage)}
>
{errorMessage}
</p>
)}
</div>
{values && values.length > 0 && (
<div
className={cn(
'w-full mt-1.5 flex flex-col gap-1.5',
className?.fileItemContainer
)}
>
{values.map((file, idx) => (
<div
key={idx}
className={cn('w-full flex flex-row items-center gap-2')}
>
<div className='p-2 rounded-full bg-primary/10'>
<Icon
icon='basil:file-solid'
width={24}
height={24}
className='text-blue-500'
/>
</div>
<div className='w-full text-sm'>
<p>{file.name}</p>
</div>
<Button
variant='ghost'
color='error'
onClick={() => {
onDelete?.(idx);
}}
className='rounded-full text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='fluent:delete-12-regular' width={24} height={24} />
</Button>
</div>
))}
</div>
)}
</div>
);
};
export default DropFileInput;
+2 -2
View File
@@ -49,8 +49,8 @@ const NumberInput = ({
onValueChange={valueChangeHandler} onValueChange={valueChangeHandler}
decimalScale={decimalScale} decimalScale={decimalScale}
allowNegative={allowNegative} allowNegative={allowNegative}
startAdornment={inputPrefix} inputPrefix={inputPrefix}
endAdornment={inputSuffix} inputSuffix={inputSuffix}
{...restProps} {...restProps}
/> />
); );
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { ChangeEvent } from 'react';
import {
PatternFormat,
NumberFormatBase,
NumberFormatBaseProps,
OnValueChange,
} from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface PatternInputProps extends Omit<TextInputProps, 'type'> {
/**
* Format pattern, contoh: "##/##/####", "(###) ###-####", "####-####-####"
*/
format: string;
/** Mask karakter kosong, misal "_" */
mask?: string;
/** Menampilkan mask walau value kosong */
allowEmptyFormatting?: boolean;
/** Placeholder karakter format, default: "#" */
patternChar?: string;
/** Jika true, izinkan huruf (A-Z) selain angka */
inputVehicleNumber?: boolean;
type?: 'text' | 'password' | 'tel';
}
/**
* PatternInput tetap backward-compatible dengan Storybook
* tapi bisa menerima huruf jika `allowCharacters={true}`
*/
const PatternInput = ({
type = 'text',
format,
mask = '_',
allowEmptyFormatting = false,
patternChar = '#',
inputVehicleNumber = false,
onChange,
...restProps
}: PatternInputProps) => {
const handleValueChange: OnValueChange = (values, { event }) => {
const newEvent = event as ChangeEvent<HTMLInputElement> | undefined;
if (newEvent) {
newEvent.target.value = values.value.toUpperCase();
onChange?.(newEvent);
}
};
if (inputVehicleNumber) {
return (
<NumberFormatBase
{...restProps}
type={type}
customInput={TextInput}
format={(value) => {
const clean = value.replace(/[^a-z0-9]/gi, '').toUpperCase();
const match = clean.match(/^([A-Z]{0,2})(\d{0,4})([A-Z]{0,3})$/);
if (!match) return clean;
const [, prefix, number, suffix] = match;
return [prefix, number, suffix].filter(Boolean).join(' ');
}}
removeFormatting={(val) => val.replace(/\s+/g, '')}
isValidInputCharacter={(char) => /^[a-z0-9]$/i.test(char)}
getCaretBoundary={(val) =>
Array(val.length + 1)
.fill(true)
.map(Boolean)
}
onValueChange={handleValueChange}
/>
);
}
return (
<PatternFormat
{...restProps}
type={type}
format={format}
mask={mask}
allowEmptyFormatting={allowEmptyFormatting}
patternChar={patternChar}
customInput={TextInput}
onValueChange={handleValueChange}
/>
);
};
export default PatternInput;
+63 -11
View File
@@ -1,22 +1,23 @@
'use client'; 'use client';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import Select, { import Select, {
OptionProps, OptionProps,
GroupBase, GroupBase,
InputActionMeta, InputActionMeta,
MultiValue, MultiValue,
SingleValue, SingleValue,
components as ReactSelectComponents,
ControlProps,
} from 'react-select'; } from 'react-select';
import CreatableSelect from 'react-select/creatable'; import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated'; import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper'; import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client'; import { httpClientFetcher } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { isResponseSuccess } from '@/lib/api-helper';
export interface OptionType { export interface OptionType {
value: string | number; value: string | number;
@@ -53,6 +54,8 @@ interface SelectInputBaseProps<T = OptionType> {
openMenu?: boolean; openMenu?: boolean;
delay?: number; delay?: number;
onInputChange?: (search: string) => void; onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
menuPortalTarget?: HTMLElement | null;
} }
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> { interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
@@ -63,6 +66,33 @@ interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
const animatedComponents = makeAnimated(); const animatedComponents = makeAnimated();
const CustomControl = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: ControlProps<Option, IsMulti, Group>
) => {
const { children } = props;
const customProps = props.selectProps as unknown as {
shouldShowAdornment?: boolean;
startAdornment?: ReactNode;
};
const shouldShowAdornment = customProps.shouldShowAdornment ?? false;
const startAdornment = customProps.startAdornment;
return (
<ReactSelectComponents.Control {...props}>
<div className='flex-1 px-4! py-1.5 gap-1 flex items-center'>
{shouldShowAdornment && startAdornment}
{children}
</div>
</ReactSelectComponents.Control>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => { const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const { const {
label, label,
@@ -87,15 +117,25 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
delay = 300, delay = 300,
createables = false, createables = false,
onInputChange, onInputChange,
startAdornment,
menuPortalTarget,
} = props; } = props;
const [internalInputValue, setInternalInputValue] = useState(''); const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay); const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const shouldShowAdornment = startAdornment && !internalInputValue;
const components = useMemo(() => { const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {}; const base = isAnimated ? animatedComponents : {};
return { ...base, IndicatorSeparator: () => null }; const customComponents = { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
if (startAdornment) {
customComponents.Control = CustomControl;
}
return customComponents;
}, [isAnimated, startAdornment]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'input-change') setInternalInputValue(val);
@@ -139,9 +179,12 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
> >
{label} {label}
{required && ( {required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'> <span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
</>
)} )}
</span> </span>
)} )}
@@ -149,11 +192,12 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
<SelectComponent<T, boolean, GroupBase<T>> <SelectComponent<T, boolean, GroupBase<T>>
instanceId='select' instanceId='select'
value={value ?? (isMulti ? [] : null)} value={value ?? (isMulti ? [] : null)}
onChange={handleChange} onChange={onChange ? handleChange : undefined}
options={options} options={options}
menuIsOpen={openMenu} menuIsOpen={openMenu}
inputValue={internalInputValue} inputValue={internalInputValue}
onInputChange={internalInputChangeHandler} onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti} isMulti={isMulti}
isDisabled={isDisabled} isDisabled={isDisabled}
isLoading={isLoading} isLoading={isLoading}
@@ -163,6 +207,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
placeholder={placeholder} placeholder={placeholder}
className={cn('w-full', className?.select)} className={cn('w-full', className?.select)}
classNames={{ classNames={{
...(!startAdornment && {
control: ({ isFocused, isDisabled }) => control: ({ isFocused, isDisabled }) =>
cn( cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!', 'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
@@ -174,6 +219,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
} }
), ),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'), valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
}),
placeholder: () => placeholder: () =>
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }), cn({ 'text-gray-400': !isError, 'text-red-300!': isError }),
singleValue: () => singleValue: () =>
@@ -190,7 +236,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'), cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'),
menuList: () => cn('p-2! max-h-60 overflow-auto'), menuList: () => cn('p-2! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) => option: ({ isFocused, isSelected }) =>
cn('mt-1 px-3 py-2 rounded cursor-pointer!', { cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused, 'bg-indigo-600 text-white': isFocused,
'bg-blue-500!': isSelected, 'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected, 'text-gray-700': !isFocused && !isSelected,
@@ -211,8 +257,14 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
...components, ...components,
...(optionComponent ? { Option: optionComponent } : {}), ...(optionComponent ? { Option: optionComponent } : {}),
}} }}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={ menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined typeof document !== 'undefined'
? (menuPortalTarget ?? document.body)
: undefined
} }
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
@@ -229,8 +281,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const useSelect = <T,>( const useSelect = <T,>(
basePath: string, basePath: string,
valueKey: keyof T, valueKey: keyof T | string,
labelKey: keyof T, labelKey: keyof T | string,
searchKey: string = 'search', searchKey: string = 'search',
params?: { [key: string]: string } params?: { [key: string]: string }
) => { ) => {
@@ -241,7 +293,7 @@ const useSelect = <T,>(
[searchKey]: inputValue ?? '', [searchKey]: inputValue ?? '',
...params, ...params,
}).toString(); }).toString();
}, [inputValue, searchKey]); }, [inputValue, searchKey, params]);
const optionsUrl = `${basePath}?${optionsUrlParams}`; const optionsUrl = `${basePath}?${optionsUrlParams}`;
+1 -1
View File
@@ -83,7 +83,7 @@ const TextArea = ({
<textarea <textarea
className={cn( className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white', 'textarea h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
+83 -1
View File
@@ -31,6 +31,8 @@ export interface TextInputProps {
errorMessage?: string; errorMessage?: string;
startAdornment?: ReactNode; startAdornment?: ReactNode;
endAdornment?: ReactNode; endAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
} }
@@ -48,6 +50,8 @@ const TextInput = ({
errorMessage, errorMessage,
startAdornment, startAdornment,
endAdornment, endAdornment,
inputPrefix,
inputSuffix,
disabled = false, disabled = false,
required = false, required = false,
onChange, onChange,
@@ -85,9 +89,86 @@ const TextInput = ({
</label> </label>
)} )}
{inputPrefix || inputSuffix ? (
<div className='relative flex'>
{inputPrefix && (
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200', 'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
{inputPrefix}
</div>
)}
<div
className={cn(
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white',
{
'border-error': isError,
'border-success!': isValid,
'rounded-l-none!': inputPrefix,
'rounded-r-none!': inputSuffix,
'input-disabled': disabled,
'cursor-not-allowed': disabled,
'bg-gray-50': disabled,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
<input
type={type}
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn(
'grow bg-transparent outline-none',
{
'cursor-not-allowed': disabled,
'text-gray-500': disabled,
},
className?.input
)}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
</div>
{inputSuffix && (
<div
className={cn(
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
{inputSuffix}
</div>
)}
</div>
) : (
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -118,6 +199,7 @@ const TextInput = ({
</div> </div>
)} )}
</div> </div>
)}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full text-sm opacity-60'>{bottomLabel}</p>
+40 -17
View File
@@ -1,35 +1,29 @@
'use client'; 'use client';
import { RefObject } from 'react'; import { MouseEventHandler, RefObject, useState } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import Button from '@/components/Button'; import Button, { ButtonProps } from '@/components/Button';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
interface ConfirmationModalProps { export interface ConfirmationModalProps {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
type?: 'info' | 'success' | 'error'; type?: 'info' | 'success' | 'error';
text?: string; text?: string;
closeOnBackdrop?: boolean; closeOnBackdrop?: boolean;
primaryButton?: { primaryButton?: ButtonProps & {
text?: string; text?: string;
color?: Color;
isLoading?: boolean;
onClick?: () => void;
}; };
secondaryButton?: { secondaryButton?: ButtonProps & {
text?: string; text?: string;
color?: Color;
isLoading?: boolean;
onClick?: () => void;
}; };
className?: { className?: {
modal?: string; modal?: string;
modalBox?: string; modalBox?: string;
}; };
children?: React.ReactNode;
} }
const ConfirmationModal = ({ const ConfirmationModal = ({
@@ -40,11 +34,24 @@ const ConfirmationModal = ({
primaryButton, primaryButton,
secondaryButton, secondaryButton,
className, className,
children,
}: ConfirmationModalProps) => { }: ConfirmationModalProps) => {
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
const closeModalHandler = () => { const closeModalHandler = () => {
ref.current?.close(); ref.current?.close();
}; };
const primaryButtonClickHandler: MouseEventHandler<
HTMLButtonElement
> = async (event) => {
setIsPrimaryButtonLoading(true);
await primaryButton?.onClick?.(event);
setIsPrimaryButtonLoading(false);
};
return ( return (
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}> <Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
<div className='w-full flex flex-col gap-4'> <div className='w-full flex flex-col gap-4'>
@@ -90,13 +97,20 @@ const ConfirmationModal = ({
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'} {text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p> </p>
{children && <div className='w-full'>{children}</div>}
<div className='w-full flex flex-row gap-2'> <div className='w-full flex flex-row gap-2'>
{secondaryButton && secondaryButton.text && ( {secondaryButton && secondaryButton.text && (
<Button <Button
{...secondaryButton}
variant='ghost' variant='ghost'
color={secondaryButton?.color ?? 'none'} color={secondaryButton?.color}
isLoading={secondaryButton?.isLoading} isLoading={secondaryButton?.isLoading}
disabled={secondaryButton?.isLoading} disabled={
secondaryButton?.isLoading !== undefined
? secondaryButton?.isLoading
: isPrimaryButtonLoading
}
onClick={closeModalHandler} onClick={closeModalHandler}
className='grow' className='grow'
> >
@@ -106,10 +120,19 @@ const ConfirmationModal = ({
{primaryButton && primaryButton.text && ( {primaryButton && primaryButton.text && (
<Button <Button
{...primaryButton}
color={primaryButton?.color ?? 'info'} color={primaryButton?.color ?? 'info'}
onClick={primaryButton?.onClick} onClick={primaryButtonClickHandler}
isLoading={primaryButton?.isLoading} isLoading={
disabled={primaryButton?.isLoading} primaryButton?.isLoading !== undefined
? primaryButton?.isLoading
: isPrimaryButtonLoading
}
disabled={
primaryButton?.isLoading !== undefined
? primaryButton?.isLoading
: isPrimaryButtonLoading
}
className='grow' className='grow'
> >
{primaryButton?.text ?? 'Ya'} {primaryButton?.text ?? 'Ya'}
@@ -0,0 +1,70 @@
'use client';
import { ChangeEventHandler, useId, useState } from 'react';
import ConfirmationModal, {
ConfirmationModalProps,
} from '@/components/modal/ConfirmationModal';
import TextArea from '@/components/input/TextArea';
import { Color } from '@/types/theme';
interface ConfirmationModalWithNotesProps
extends Omit<ConfirmationModalProps, 'children' | 'primaryButton'> {
rows?: number;
placeholder?: string;
primaryButton?: {
text?: string;
color?: Color;
isLoading?: boolean;
onClick?: (notes: string) => void;
};
}
const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
ref,
type = 'info',
text,
closeOnBackdrop,
primaryButton,
secondaryButton,
className,
rows = 3,
placeholder = 'Catatan...',
}) => {
const randomId = useId();
const [notes, setNotes] = useState('');
const notesChangeHandler: ChangeEventHandler<HTMLTextAreaElement> = (e) => {
setNotes(e.target.value);
};
return (
<ConfirmationModal
ref={ref}
type={type}
text={text}
closeOnBackdrop={closeOnBackdrop}
primaryButton={{
...primaryButton,
onClick: () => {
primaryButton?.onClick?.(notes);
setNotes('');
},
}}
secondaryButton={secondaryButton}
className={className}
>
<TextArea
name={randomId}
placeholder={placeholder}
value={notes}
onChange={notesChangeHandler}
rows={rows}
/>
</ConfirmationModal>
);
};
export default ConfirmationModalWithNotes;
+202 -9
View File
@@ -4,8 +4,16 @@ import StepItem from '@/components/steps/StepItem';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { BaseApproval, BaseGroupedApproval } from '@/types/api/api-general'; import {
BaseApiResponse,
BaseApproval,
BaseGroupedApproval,
} from '@/types/api/api-general';
import { ApprovalLine } from '@/types/config/constant'; import { ApprovalLine } from '@/types/config/constant';
import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { useCallback, useMemo } from 'react';
export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE'; export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE';
@@ -120,7 +128,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
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;
if (!approvalGroup && currentStepNumber <= lastStepNumber) { if (!approvalGroup && currentStepNumber <= lastStepNumber) {
throw new Error( throw new Error(
@@ -130,18 +138,27 @@ export const formatGroupedApprovalsToApprovalSteps = (
if (!approvalGroup) { if (!approvalGroup) {
const isWaiting = currentStepNumber === latestApproval.step_number + 1; const isWaiting = currentStepNumber === latestApproval.step_number + 1;
const isPreviousApprovalRejected =
groupedApprovals[groupedApprovals.length - 1].approvals[0].action ===
'REJECTED';
return { return {
name: approvalLineItem.step_name, name: approvalLineItem.step_name,
status: isWaiting ? 'WAITING' : 'IDLE', status: isPreviousApprovalRejected
? 'IDLE'
: isWaiting
? 'WAITING'
: 'IDLE',
}; };
} }
let approvalStatus: ApprovalStepStatus; let approvalStatus: ApprovalStepStatus = 'IDLE';
if (approvalGroup.step_number <= latestApproval.step_number) { if (approvalGroup.step_number <= latestApproval.step_number) {
switch (approvalGroup.approvals[0].action) { if (approvalGroup.approvals) {
switch (approvalGroup?.approvals[0]?.action) {
case 'CREATED': case 'CREATED':
case 'UPDATED':
case 'APPROVED': case 'APPROVED':
approvalStatus = 'APPROVED'; approvalStatus = 'APPROVED';
break; break;
@@ -154,19 +171,20 @@ export const formatGroupedApprovalsToApprovalSteps = (
approvalStatus = 'IDLE'; approvalStatus = 'IDLE';
break; break;
} }
}
} else if (approvalGroup.step_number === latestApproval.step_number + 1) { } else if (approvalGroup.step_number === latestApproval.step_number + 1) {
approvalStatus = 'WAITING'; approvalStatus = 'WAITING';
} else { } else {
approvalStatus = 'IDLE'; approvalStatus = 'IDLE';
} }
const approvalLogs: ApprovalStepLog[] = approvalGroup.approvals.map( const approvalLogs: ApprovalStepLog[] = approvalGroup.approvals
(approval) => ({ ? approvalGroup.approvals.map((approval) => ({
action_by: approval.action_by.name, action_by: approval.action_by.name,
date: approval.action_at, date: approval.action_at,
notes: approval.notes, notes: approval.notes,
}) }))
); : [];
return { return {
name: approvalGroup.step_name, name: approvalGroup.step_name,
@@ -179,3 +197,178 @@ export const formatGroupedApprovalsToApprovalSteps = (
}; };
export default ApprovalSteps; export default ApprovalSteps;
/**
* Mengubah array BaseApproval (datar) menjadi BaseGroupedApproval (berkelompok).
*/
const groupApprovalsByStep = (
approvals: BaseApproval[]
): BaseGroupedApproval[] => {
const groups: Record<number, BaseGroupedApproval> = {};
for (const approval of approvals) {
if (!groups[approval.step_number]) {
groups[approval.step_number] = {
step_number: approval.step_number,
step_name: approval.step_name,
approvals: [],
};
}
groups[approval.step_number].approvals.push(approval);
}
return Object.values(groups);
};
/**
* Mengubah array BaseGroupedApproval (berkelompok) kembali menjadi BaseApproval[] (datar).
*/
const flattenGroupedApprovals = (
groupedApprovals: BaseGroupedApproval[]
): BaseApproval[] => {
return groupedApprovals.flatMap((group) => group.approvals);
};
/**
* Type guard untuk memeriksa apakah data adalah BaseGroupedApproval[].
*/
const isGroupedApprovalData = (
data: BaseApproval[] | BaseGroupedApproval[]
): data is BaseGroupedApproval[] => {
if (!data || data.length === 0) {
return true;
}
const firstElement = data[0];
return (
typeof firstElement === 'object' &&
firstElement !== null &&
'approvals' in firstElement &&
Array.isArray(firstElement.approvals)
);
};
const useApprovalSteps = ({
latestApproval,
approvalLines,
moduleName,
moduleId,
params,
}: {
latestApproval: BaseApproval | undefined;
approvalLines: ApprovalLine;
moduleName: string;
moduleId: string;
params?: {
page?: number;
limit: number;
search?: string;
group_step_number?: boolean;
};
}) => {
// Membuat URL Parameters
const paramString = new URLSearchParams({
page: params?.page?.toString() || '',
limit: params?.limit?.toString() || '',
search: params?.search || '',
}).toString();
// fetching data approvals
const SWR_KEY_APPROVALS =
moduleName && moduleId
? `/approvals?module_name=${moduleName}&module_id=${moduleId}${
params ? `&${paramString}` : ''
}`
: null;
const {
data: approvalData,
isLoading: approvalIsLoading,
mutate: mutateApprovals,
} = useSWR(SWR_KEY_APPROVALS, async (url) => {
return await httpClientFetcher<
BaseApiResponse<BaseApproval[] | BaseGroupedApproval[]>
>(url);
});
// Fungsi Refresh
const refresh = useCallback(async () => {
await mutateApprovals();
}, [mutateApprovals]);
const { groupedApprovals } = useMemo(() => {
const rawData = isResponseSuccess(approvalData)
? approvalData.data
: undefined;
let processedGroupedApprovals: BaseGroupedApproval[] = [];
if (rawData) {
if (isGroupedApprovalData(rawData)) {
processedGroupedApprovals = rawData;
} else {
processedGroupedApprovals = groupApprovalsByStep(
rawData as BaseApproval[]
);
}
}
return {
groupedApprovals: processedGroupedApprovals,
};
}, [approvalData]);
const isLoading = approvalIsLoading;
// Formatting Akhir
const approvals = useMemo(() => {
if (isLoading || !approvalLines.length || !latestApproval) {
return [];
}
try {
return formatGroupedApprovalsToApprovalSteps(
approvalLines,
groupedApprovals,
latestApproval
);
} catch (error) {
console.warn('Gagal memformat approval steps:', error);
return [];
}
}, [isLoading, approvalLines, groupedApprovals, latestApproval]);
// Raw Data Approvals
const rawDataApprovals = useMemo(() => {
const rawData = isResponseSuccess(approvalData)
? approvalData.data
: undefined;
if (!rawData) {
return undefined;
}
const isDataCurrentlyGrouped = isGroupedApprovalData(rawData);
const wantsGrouped = params?.group_step_number !== false;
if (wantsGrouped) {
if (isDataCurrentlyGrouped) {
return rawData as BaseGroupedApproval[];
} else {
return groupApprovalsByStep(rawData as BaseApproval[]);
}
} else {
if (isDataCurrentlyGrouped) {
return flattenGroupedApprovals(rawData as BaseGroupedApproval[]);
} else {
return rawData as BaseApproval[];
}
}
}, [approvalData, params?.group_step_number]);
// Return Hook
return {
approvals,
isLoading,
rawDataApprovals: rawDataApprovals,
refresh,
};
};
export { useApprovalSteps };
@@ -0,0 +1,507 @@
'use client';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import DropFileInput from '@/components/input/DropFileInput';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import {
UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
interface ExpenseDetailProps {
initialValues?: Expense;
}
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter();
// Modal hooks
const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
// Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const isLatestApprovalRejectedOrDone =
initialValues?.approval &&
(initialValues.approval.action === 'REJECTED' ||
initialValues.approval.step_number === 5);
const formik = useFormik<UploadRequestDocumentsFormValues>({
initialValues: {
request_documents: [],
},
validationSchema: UploadRequestDocumentsFormSchema,
onSubmit: async (values) => {
const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments(
initialValues?.id as number,
values.request_documents
);
if (isResponseSuccess(addRequestDocumentsRes)) {
toast.success(addRequestDocumentsRes.message);
window.location.reload();
} else {
toast.error(String(addRequestDocumentsRes?.message));
}
},
});
const deleteExpenseClickHandler = () => {
deleteModal.openModal();
};
const approveClickHandler = () => {
approveModal.openModal();
};
const rejectClickHandler = () => {
rejectModal.openModal();
};
// Modal confirm click handler
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
try {
await ExpenseApi.delete(initialValues?.id as number);
toast.success('Berhasil menghapus data biaya operasional!');
router.push('/expense');
} catch (error) {
toast.error('Gagal menghapus data biaya operasional!');
} finally {
deleteModal.closeModal();
setIsDeleteLoading(false);
}
};
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true);
const approveResponse = await ExpenseApi.approve(
initialValues?.id as number,
notes
);
if (isResponseSuccess(approveResponse)) {
approveModal.closeModal();
toast.success('Berhasil approve pengajuan biaya operasional!');
router.push('/expense');
} else {
approveModal.closeModal();
toast.error('Gagal approve pengajuan biaya operasional!');
}
setIsApproveLoading(false);
};
const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true);
const rejectResponse = await ExpenseApi.reject(
initialValues?.id as number,
notes
);
if (isResponseSuccess(rejectResponse)) {
rejectModal.closeModal();
toast.success('Berhasil reject pengajuan biaya operasional!');
router.push('/expense');
} else {
rejectModal.closeModal();
toast.error('Gagal reject pengajuan biaya operasional!');
}
setIsRejectLoading(false);
};
const requestDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('request_documents', true);
formik.setFieldValue('request_documents', val);
};
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
const newRequestDocuments = formik.values.request_documents;
newRequestDocuments?.splice(deletedFileIdx, 1);
formik.setFieldValue('request_documents', newRequestDocuments);
};
return (
<>
<section className='w-full max-w-7xl pb-16'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
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 Biaya Operasional
</h1>
</header>
<div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */}
{!isLatestApprovalRejectedOrDone && (
<div className='w-full max-w-3xl mx-auto flex flex-row justify-end gap-2'>
<Button
variant='outline'
color='success'
onClick={approveClickHandler}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
<Button
variant='outline'
color='error'
onClick={rejectClickHandler}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
<Button
type='button'
color='warning'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
className='px-4 ml-2'
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit
</Button>
<Button
type='button'
color='error'
onClick={deleteExpenseClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
)}
{/* TODO: add and integrate ApprovalSteps component with API */}
<div className='overflow-x-auto w-full max-w-3xl mx-auto'>
<table className='table table-sm table-zebra'>
<tbody>
<tr>
<th>Nomor PO</th>
<th>:</th>
<td>{initialValues?.po_number ?? '-'}</td>
</tr>
<tr>
<th>Nomor Referensi</th>
<th>:</th>
<td>{initialValues?.reference_number}</td>
</tr>
<tr>
<th>Lokasi</th>
<th>:</th>
<td>{initialValues?.location.name}</td>
</tr>
<tr>
<th>Kandang</th>
<th>:</th>
<td>
{initialValues?.kandangs
.map((item) => item.name)
.join(', ')}
</td>
</tr>
<tr>
<th>Vendor</th>
<th>:</th>
<td>{initialValues?.vendor.name}</td>
</tr>
<tr>
<th>Tanggal Transaksi</th>
<th>:</th>
<td>
{formatDate(
initialValues?.transaction_date,
'DD MMMM YYYY'
)}
</td>
</tr>
<tr>
<th>Tanggal Realisasi</th>
<th>:</th>
<td>
{initialValues?.realization_date
? formatDate(
initialValues?.realization_date,
'DD MMMM YYYY'
)
: '-'}
</td>
</tr>
<tr>
<th>Nama Pengaju</th>
<th>:</th>
<td>{initialValues?.created_user.name}</td>
</tr>
<tr>
<th>Nominal Biaya</th>
<th>:</th>
<td>{formatCurrency(initialValues?.nominal ?? 0)}</td>
</tr>
<tr>
<th>Nominal Sudah Bayar</th>
<th>:</th>
<td>{formatCurrency(initialValues?.paid ?? 0)}</td>
</tr>
<tr>
<th>Nominal Sisa Bayar</th>
<th>:</th>
<td>{formatCurrency(initialValues?.remaining_cost ?? 0)}</td>
</tr>
<tr>
<th>Status Pencairan</th>
<th>:</th>
<td>
<RealizationStatusBadge
approval={initialValues?.approval}
/>
</td>
</tr>
<tr>
<th>Status Biaya</th>
<th>:</th>
<td>
<ExpenseStatusBadge approval={initialValues?.approval} />
</td>
</tr>
<tr>
<th>Dokumen Pengajuan</th>
<th>:</th>
<td>
<div>
{initialValues?.request_documents.length === 0 && '-'}
{initialValues?.request_documents &&
initialValues?.request_documents.length > 0 && (
<ul className='list-disc'>
{initialValues?.request_documents.map(
(requestDocument, requestDocumentIdx) => (
<li key={requestDocumentIdx}>
<Link
href={requestDocument.url}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{requestDocument.name}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
)
)}
</ul>
)}
</div>
<div className='flex flex-col gap-2'>
<DropFileInput
name='request_documents'
values={formik.values.request_documents}
onChange={requestDocumentsChangeHandler}
onDelete={requestDocumentsDeleteHandler}
accept={{
...ACCEPTED_FILE_TYPE.PDF,
...ACCEPTED_FILE_TYPE.IMAGE,
}}
maxFiles={10}
className={{
wrapper: 'mt-2',
inputWrapper: 'flex items-center',
}}
/>
{formik.values.request_documents &&
formik.values.request_documents.length > 0 && (
<Button
onClick={formik.submitForm}
disabled={formik.isSubmitting}
isLoading={formik.isSubmitting}
className='w-fit self-end'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
)}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandang_expenses.map(
(kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.expenses.forEach(
(item) => (expenseGrandTotal += item.total_expense)
);
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.kandang.name}
</th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.expenses.map(
(expenseItem, expenseIdx) => (
<tr key={expenseIdx}>
<td>{expenseItem.nonstock.name}</td>
<td>{expenseItem.total_quantity}</td>
<td>
{formatCurrency(expenseItem.total_expense)}
</td>
<td className='w-xs'>
{expenseItem.notes ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div>
</section>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data transfer ke laying ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
text='Apakah anda yakin ingin approve data transfer ke laying ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isApproveLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={rejectModal.ref}
type='error'
text='Apakah anda yakin ingin reject data transfer ke laying ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isRejectLoading,
onClick: confirmationModalRejectClickHandler,
}}
/>
</>
);
};
export default ExpenseDetail;
@@ -0,0 +1,57 @@
import PillBadge from '@/components/PillBadge';
import { BaseApproval } from '@/types/api/api-general';
interface ExpenseStatusBadgeProps {
approval?: BaseApproval;
}
const ExpenseStatusBadge = ({ approval }: ExpenseStatusBadgeProps) => {
const isLatestApprovalRejected = approval?.action === 'REJECTED';
const latestApprovalStepNumber = approval?.step_number;
let expenseStatusPillBadgeColor:
| 'yellow'
| 'green'
| 'gray'
| 'red'
| 'purple'
| 'blue' = 'gray';
switch (latestApprovalStepNumber) {
case 1:
expenseStatusPillBadgeColor = 'yellow';
break;
case 2:
expenseStatusPillBadgeColor = 'purple';
break;
case 3:
expenseStatusPillBadgeColor = 'blue';
break;
case 4:
expenseStatusPillBadgeColor = 'red';
break;
case 5:
expenseStatusPillBadgeColor = 'green';
break;
}
if (isLatestApprovalRejected) {
expenseStatusPillBadgeColor = 'red';
}
return (
<PillBadge
content={isLatestApprovalRejected ? 'Ditolak' : approval?.step_name}
color={expenseStatusPillBadgeColor}
className='text-xs'
/>
);
};
export default ExpenseStatusBadge;
@@ -0,0 +1,699 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
Row,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import DateInput from '@/components/input/DateInput';
import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
const RowOptionsMenu = ({
type = 'dropdown',
props,
approveClickHandler,
rejectClickHandler,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Expense, unknown>;
approveClickHandler: () => void;
rejectClickHandler: () => void;
deleteClickHandler: () => void;
}) => {
const showEditButton =
props.row.original.approval.action !== 'REJECTED' &&
props.row.original.approval.step_number !== 5 &&
props.row.original.approval.action !== 'APPROVED';
const showDeleteButton = showEditButton;
// TODO: apply RBAC
const showApproveButton = showEditButton;
const showRejectButton = showEditButton;
return (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
{showEditButton && (
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
)}
{/* TODO: apply RBAC */}
{showApproveButton && (
<Button
variant='ghost'
color='success'
onClick={approveClickHandler}
className='justify-start text-sm'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
)}
{showRejectButton && (
<Button
variant='ghost'
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm'
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
)}
{showDeleteButton && (
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
)}
</RowOptionsMenuWrapper>
);
};
const ExpensesTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
locationId: '',
vendorId: '',
userId: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
transactionDate: 'transaction_date',
realizationDate: 'realization_date',
locationId: 'location_id',
vendorId: 'vendor_id',
userId: 'user_id',
},
});
const {
data: expenses,
isLoading,
mutate: refreshExpenses,
} = useSWR(
`${ExpenseApi.basePath}${getTableFilterQueryString()}`,
ExpenseApi.getAllFetcher
);
const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
undefined
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
);
const expensesColumns: ColumnDef<Expense>[] = [
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() || row.original.approval.action === 'REJECTED';
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={isCheckboxDisabled}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{
accessorKey: 'transaction_date',
header: 'Tanggal Pengajuan',
},
{
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
cell: (props) => props.getValue() ?? '-',
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location.name ?? '-',
},
{
accessorFn: (row) => row.created_user.name ?? '-',
header: 'Nama Pengaju',
},
{
accessorFn: (row) => row.vendor.name ?? '-',
header: 'Vendor',
},
{
accessorKey: 'nominal',
header: 'Nominal',
cell: (props) =>
props.row.original.nominal
? `Rp${formatCurrency(props.row.original.nominal)}`
: '-',
},
{
accessorKey: 'paid',
header: 'Sudah Bayar',
cell: (props) =>
props.row.original.paid
? `Rp${formatCurrency(props.row.original.paid)}`
: '-',
},
{
accessorKey: 'remaining_cost',
header: 'Sisa Bayar',
cell: (props) =>
props.row.original.remaining_cost
? `Rp${formatCurrency(props.row.original.remaining_cost)}`
: '-',
},
{
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge approval={props.row.original.approval} />
),
},
{
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original.approval} />
),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const approveClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
approveModal.openModal();
};
const rejectClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
rejectModal.openModal();
};
const deleteClickHandler = () => {
setSelectedExpense(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row
) => {
return row.original.approval.action !== 'REJECTED';
};
const bulkApproveClickHandler = () => {
approveModal.openModal();
};
const bulkRejectClickHandler = () => {
rejectModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ExpenseApi.delete(selectedExpense?.id as number);
refreshExpenses();
deleteModal.closeModal();
toast.success('Berhasil menghapus biaya operasional!');
setIsDeleteLoading(false);
};
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true);
const bulkApproveResponse = await ExpenseApi.bulkApprove(
selectedRowIds,
notes
);
if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses();
approveModal.closeModal();
toast.success(
`Berhasil approve ${selectedRowIds.length} data transfer ke laying!`
);
setRowSelection({});
} else {
approveModal.closeModal();
toast.error(
`Gagal approve ${selectedRowIds.length} data transfer ke laying!`
);
}
setIsApproveLoading(false);
};
const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true);
const bulkRejectResponse = await ExpenseApi.bulkReject(
selectedRowIds,
notes
);
if (isResponseSuccess(bulkRejectResponse)) {
refreshExpenses();
rejectModal.closeModal();
toast.success(
`Berhasil reject ${selectedRowIds.length} data transfer ke laying!`
);
setRowSelection({});
} else {
rejectModal.closeModal();
toast.error(
`Gagal reject ${selectedRowIds.length} data transfer ke laying!`
);
}
setIsRejectLoading(false);
};
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
);
};
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const [selectedVendor, setSelectedVendor] = useState<OptionType | null>(null);
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedVendor(val as OptionType);
updateFilter('vendorId', val ? ((val as OptionType).value as string) : '');
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const transactionDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
updateFilter('transactionDate', e.target.value);
};
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
updateFilter('realizationDate', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// 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-between items-end sm:items-center gap-4'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
<Button
href='/expense/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
{selectedRowIds.length > 0 && (
<>
{/* TODO: apply RBAC */}
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:check'
width={24}
height={24}
/>
Approve
</Button>
<Button
variant='outline'
color='error'
onClick={bulkRejectClickHandler}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:close'
width={24}
height={24}
/>
Reject
</Button>
</>
)}
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-2'>
<DateInput
required
label='Tanggal Transaksi'
name='transaction_date'
placeholder='Masukkan tanggal transaksi'
value={tableFilterState.transactionDate}
onChange={transactionDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<DateInput
required
label='Tanggal Realisasi'
name='realization_date'
placeholder='Masukkan tanggal realisasi'
value={tableFilterState.realizationDate}
onChange={realizationDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Vendor'
options={vendorOptions}
isLoading={isLoadingVendorOptions}
value={selectedVendor}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'col-span-12 max-w-28 justify-self-end',
}}
/>
</div>
</div>
</div>
<Table<Expense>
data={isResponseSuccess(expenses) ? expenses?.data : []}
columns={expensesColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(expenses) && expenses?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data biaya operasional ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
text={`Apakah anda yakin ingin approve data biaya operasional ini (${selectedRowIds.length} data)?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isApproveLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={rejectModal.ref}
type='error'
text={`Apakah anda yakin ingin reject data biaya operasional ini (${selectedRowIds.length} data)?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isRejectLoading,
onClick: confirmationModalRejectClickHandler,
}}
/>
</>
);
};
export default ExpensesTable;
@@ -0,0 +1,39 @@
import PillBadge from '@/components/PillBadge';
import { BaseApproval } from '@/types/api/api-general';
interface RealizationStatusBadgeProps {
approval?: BaseApproval;
}
const RealizationStatusBadge = ({ approval }: RealizationStatusBadgeProps) => {
const isLatestApprovalRejected = approval?.action === 'REJECTED';
const isExpenseRealized = approval?.step_number && approval.step_number >= 4;
const realizationStatus = isExpenseRealized
? 'Sudah Realisasi'
: 'Belum Realisasi';
let realizationStatusPillBadgeColor:
| 'yellow'
| 'green'
| 'gray'
| 'red'
| 'purple'
| 'blue' = isExpenseRealized ? 'green' : 'yellow';
if (isLatestApprovalRejected) {
realizationStatusPillBadgeColor = 'red';
}
return (
<PillBadge
content={isLatestApprovalRejected ? 'Ditolak' : realizationStatus}
color={realizationStatusPillBadgeColor}
className='text-xs'
/>
);
};
export default RealizationStatusBadge;
@@ -0,0 +1,237 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Collapse from '@/components/Collapse';
import Card from '@/components/Card';
import Table from '@/components/Table';
import CheckboxInput from '@/components/input/CheckboxInput';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { cn, convertRowSelectionArrToObj } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { KandangApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
interface ExpenseKandangsTableProps {
locationId?: number;
type: 'add' | 'edit' | 'detail';
selectedKandangs: {
id: number;
name: string;
}[];
onChange: (kandangs: { id: number; name: string }[]) => void;
className?: {
wrapper?: string;
};
}
const ExpenseKandangsTable = ({
type,
locationId,
selectedKandangs,
onChange,
className,
}: ExpenseKandangsTableProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
picSort: '',
locationId,
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
picSort: 'sort_pic',
locationId: 'location_id',
},
});
const { data: kandangs, isLoading } = useSWR(
locationId ? `${KandangApi.basePath}${getTableFilterQueryString()}` : null,
KandangApi.getAllFetcher
);
const [open, setOpen] = useState(
isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false
);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id))
);
const kandangsColumns: ColumnDef<Kandang>[] = [
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
disabled={type === 'detail'}
/>
</div>
),
cell: ({ row }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect() || type === 'detail'}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'pic',
header: 'PIC',
cell: (props) => props.row.original.pic.name,
},
];
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
);
useEffect(() => {
if (locationId) updateFilter('locationId', locationId);
}, [locationId, updateFilter]);
useEffect(() => {
setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false);
}, [kandangs, isResponseSuccess]);
useEffect(() => {
if (Object.keys(rowSelection).length !== 0 && isResponseSuccess(kandangs)) {
const formattedSelectedKandangs = Object.keys(rowSelection).map(
(item) => {
const selectedKandang = kandangs.data.find(
(kandang) => kandang.id === parseInt(item)
);
return {
id: parseInt(item),
name: selectedKandang?.name ?? 'Kandang tidak ditemukan!',
};
}
);
onChange(formattedSelectedKandangs);
} else {
onChange([]);
}
}, [rowSelection]);
useEffect(() => {
if (
selectedKandangs.length === 0 &&
Object.keys(rowSelection).length !== 0
) {
setRowSelection({});
}
}, [selectedKandangs]);
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]);
return (
<Card
className={{
wrapper: className?.wrapper,
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'>Pilih Kandang</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 first:flex first:flex-row first:justify-start',
paginationClassName: cn({
hidden:
isResponseSuccess(kandangs) &&
kandangs?.meta?.total_pages === 1,
}),
}}
/>
</Collapse>
</Card>
);
};
export default ExpenseKandangsTable;
@@ -0,0 +1,144 @@
import * as Yup from 'yup';
import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper';
type ExpenseFormSchemaType = {
location?: {
value: number;
label: string;
};
transaction_date?: string;
kandangs?: { id: number; name: string }[];
vendor?: {
value: number;
label: string;
};
existing_documents?: { name: string; url: string }[];
request_documents?: File[];
kandangExpenses: {
kandangId: number;
expenses: {
nonstock?: {
value: number;
label: string;
};
totalQuantity?: number;
totalExpense?: number;
notes?: string;
}[];
}[];
};
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
Yup.object({
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).required('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
kandangs: Yup.array()
.of(
Yup.object({
id: Yup.number().required('Kandang wajib dipilih!'),
name: Yup.string().required('Kandang wajib dipilih!'),
})
)
.min(1, 'Kandang wajib dipilih!')
.required('Kandang wajib dipilih!'),
vendor: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).required('Vendor wajib diisi!'),
existing_documents: Yup.array().of(
Yup.object({
name: Yup.string().required(),
url: Yup.string().required(),
})
),
request_documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
kandangExpenses: Yup.array()
.of(
Yup.object({
kandangId: Yup.number().min(1, 'Wajib memilih kandang!').required(),
expenses: Yup.array()
.of(
Yup.object({
nonstock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).required('Nonstock wajib diisi!'),
totalQuantity: Yup.number().required(
'Total kuantitas wajib diisi!'
),
totalExpense: Yup.number().required('Total biaya wajib diisi!'),
notes: Yup.string(),
})
)
.min(1, 'Kandang harus memiliki setidaknya 1 biaya!')
.required('Biaya kandang wajib diisi!'),
})
)
.min(1, 'Biaya kandang wajib diisi!')
.required('Biaya kandang wajib diisi!'),
});
export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema;
export const UploadRequestDocumentsFormSchema = Yup.object({
request_documents: Yup.array().of(Yup.mixed<File>().required()).required(),
});
export type ExpenseRequestFormValues = Yup.InferType<
typeof ExpenseRequestFormSchema
>;
export type UploadRequestDocumentsFormValues = Yup.InferType<
typeof UploadRequestDocumentsFormSchema
>;
export const getExpenseFormInitialValues = (
initialValues?: Expense
): ExpenseRequestFormValues => {
return {
location: initialValues?.location
? {
value: initialValues.location.id,
label: initialValues.location.name,
}
: undefined,
transaction_date: initialValues?.transaction_date
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
: undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.id,
name: kandang.name,
})),
vendor: initialValues?.vendor
? {
value: initialValues.vendor.id,
label: initialValues.vendor.name,
}
: undefined,
existing_documents: initialValues?.request_documents,
request_documents: [],
kandangExpenses: initialValues?.kandang_expenses
? initialValues.kandang_expenses.map((kandangExpense) => ({
kandangId: kandangExpense.kandang.id,
expenses: kandangExpense.expenses.map((expenseItem) => ({
nonstock: {
value: expenseItem.nonstock.id,
label: expenseItem.nonstock.name,
},
totalQuantity: expenseItem.total_quantity,
totalExpense: expenseItem.total_expense,
notes: expenseItem.notes,
})),
}))
: [],
};
};
@@ -0,0 +1,492 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
import DropFileInput from '@/components/input/DropFileInput';
import ExpenseRequestKandangDetailExpense from '@/components/pages/expense/form/ExpenseRequestKandangDetailExpense';
import {
ExpenseRequestFormSchema,
ExpenseRequestFormValues,
getExpenseFormInitialValues,
UpdateExpenseRequestFormSchema,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
Expense,
CreateExpensePayload,
UpdateExpensePayload,
} from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense';
import { cn, sleep } from '@/lib/helper';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { Supplier } from '@/types/api/master-data/supplier';
interface ExpenseFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Expense;
}
// TODO: integrate this with real API
const ExpenseRequestForm = ({
type = 'add',
initialValues,
}: ExpenseFormProps) => {
const router = useRouter();
// Modal hooks
const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
const createExpenseHandler = useCallback(
async (payload: CreateExpensePayload) => {
const createExpenseRes = await ExpenseApi.create(
ExpenseApi.convertPayloadToFormData(payload)
);
if (isResponseError(createExpenseRes)) {
setExpenseFormErrorMessage(createExpenseRes.message);
return;
}
toast.success(createExpenseRes?.message as string);
router.push('/expense');
},
[router]
);
const updateExpenseHandler = useCallback(
async (expenseId: number, payload: UpdateExpensePayload) => {
const updateExpenseRes = await ExpenseApi.update(
expenseId,
ExpenseApi.convertPayloadToFormData(payload)
);
if (updateExpenseRes?.status === 'error') {
setExpenseFormErrorMessage(updateExpenseRes.message);
return;
}
toast.success(updateExpenseRes?.message as string);
router.refresh();
router.push('/expense');
},
[router]
);
const formik = useFormik<ExpenseRequestFormValues>({
initialValues: getExpenseFormInitialValues(initialValues),
validationSchema:
type === 'edit'
? UpdateExpenseRequestFormSchema
: ExpenseRequestFormSchema,
onSubmit: async (values) => {
setExpenseFormErrorMessage('');
const expensePayload: CreateExpensePayload = {
locationId: values.location?.value as number,
kandangIds: values.kandangs
? values.kandangs.map((item) => item.id)
: [],
transaction_date: values.transaction_date as string,
vendorId: values.vendor?.value as number,
request_documents: values.request_documents as File[],
kandang_expenses: values.kandangExpenses.map((kandangExpense) => ({
kandangId: kandangExpense.kandangId,
expenses: kandangExpense.expenses.map((expenseItem) => ({
nonstockId: expenseItem.nonstock?.value as number,
total_quantity: expenseItem.totalQuantity as number,
total_expense: expenseItem.totalExpense as number,
notes: expenseItem.notes,
})),
})),
};
switch (type) {
case 'add':
await createExpenseHandler(expensePayload);
break;
case 'edit':
await updateExpenseHandler(
initialValues?.id as number,
expensePayload
);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true);
formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []);
formik.setFieldValue('kandangExpenses', []);
};
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
const newKandangExpenses = [...(formik.values.kandangExpenses ?? [])];
// add new kandangExpenses
kandangs.forEach((kandangItem) => {
const isKandangExistInKandangExpense = newKandangExpenses.find(
(kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id
);
if (isKandangExistInKandangExpense) return;
newKandangExpenses.push({
kandangId: kandangItem.id,
expenses: [
{
nonstock: undefined,
totalExpense: undefined,
totalQuantity: undefined,
notes: '',
},
],
});
});
// prune kandangExpenses
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedKandangExpensesIdx: number[] = [];
newKandangExpenses.forEach((kandangExpense, idx) => {
const isKandangExpenseValid = kandangIds.has(kandangExpense.kandangId);
if (!isKandangExpenseValid) {
deletedKandangExpensesIdx.push(idx);
}
});
deletedKandangExpensesIdx.forEach((deletedKandangExpenseIdx) => {
newKandangExpenses.splice(deletedKandangExpenseIdx, 1);
});
formik.setFieldValue('kandangExpenses', newKandangExpenses);
};
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('vendor', true);
formik.setFieldValue('vendor', val);
};
const requestDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('request_documents', true);
formik.setFieldValue('request_documents', val);
};
const deleteExpenseClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalRejectClickHandler = async () => {
await sleep(750);
rejectModal.closeModal();
toast.success('Berhasil melakukan reject biaya operasional!');
};
const confirmationModalApproveClickHandler = async () => {
await sleep(750);
approveModal.closeModal();
toast.success('Berhasil melakukan approve biaya operasional!');
};
const confirmationModalDeleteClickHandler = async () => {
await ExpenseApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Expense!');
router.push('/expense');
};
useEffect(() => {
formikSetValues(getExpenseFormInitialValues(initialValues));
}, [formikSetValues, getExpenseFormInitialValues, initialValues]);
return (
<>
<section className='w-full max-w-5xl'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
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'>
{type === 'add' && 'Tambah Biaya Operasional'}
{type === 'edit' && 'Edit Biaya Operasional'}
{type === 'detail' && 'Detail Biaya Operasional'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Lokasi'
required
placeholder='Pilih Lokasi'
value={formik.values.location}
onChange={locationChangeHandler}
options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue}
className={{ wrapper: 'col-span-12 sm:col-span-6' }}
/>
<DateInput
name='transaction_date'
label='Tanggal Transaksi'
required
value={formik.values.transaction_date}
onChange={formik.handleChange}
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
<ExpenseKandangsTable
type={type}
locationId={formik.values.location?.value}
selectedKandangs={formik.values.kandangs ?? []}
onChange={kandangsChangeHandler}
className={{
wrapper: 'w-full col-span-12',
}}
/>
<SelectInput
label='Vendor'
required
placeholder='Pilih Vendor'
value={formik.values.vendor}
onChange={vendorChangeHandler}
options={vendorOptions}
isLoading={isLoadingVendorOptions}
onInputChange={setVendorInputValue}
className={{ wrapper: 'col-span-12' }}
/>
<DropFileInput
label='Dokumen Pengajuan'
name='request_documents'
values={formik.values.request_documents}
onChange={requestDocumentsChangeHandler}
accept={{
...ACCEPTED_FILE_TYPE.PDF,
...ACCEPTED_FILE_TYPE.IMAGE,
}}
className={{
wrapper: 'col-span-12',
inputWrapper: 'h-12 flex items-center',
}}
/>
{formik.values.existing_documents &&
formik.values.existing_documents.length > 0 && (
<div className='w-full col-span-12'>
<ul className='pl-4 list-disc'>
{formik.values.existing_documents.map(
(existingDocument, existingDocumentIdx) => (
<li key={existingDocumentIdx}>
<Link
href={existingDocument.url}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{existingDocument.name}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
)
)}
</ul>
</div>
)}
<ExpenseRequestKandangDetailExpense
formik={formik}
className={{
wrapper: 'col-span-12',
}}
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteExpenseClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
)}
</div>
)}
{type !== 'detail' && (
<div
className={cn('flex flex-row justify-end gap-2', {
'w-full': type === 'add',
})}
>
<Button type='reset' color='warning' className='px-4'>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4'
>
Submit
</Button>
</div>
)}
</div>
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data Expense ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
{type === 'detail' && (
<>
<ConfirmationModal
ref={approveModal.ref}
type='success'
text='Apakah anda yakin ingin approve data transfer ke laying ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModal
ref={rejectModal.ref}
type='error'
text='Apakah anda yakin ingin reject data transfer ke laying ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: confirmationModalRejectClickHandler,
}}
/>
</>
)}
</>
);
};
export default ExpenseRequestForm;
@@ -0,0 +1,290 @@
'use client';
import { FormikContextType } from 'formik';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import NumberInput from '@/components/input/NumberInput';
import TextInput from '@/components/input/TextInput';
import Button from '@/components/Button';
import { ExpenseRequestFormValues } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { cn } from '@/lib/helper';
import { NonstockApi } from '@/services/api/master-data';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ExpenseRequestKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRequestFormValues>;
className?: {
wrapper?: string;
};
}
const ExpenseRequestKandangDetailExpense: React.FC<
ExpenseRequestKandangDetailExpenseProps
> = ({ type, formik, className }) => {
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const nonstockChangeHandler = (
kandangExpenseIdx: number,
expenseIdx: number,
val: OptionType | OptionType[] | null
) => {
formik.setFieldTouched(
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
true
);
formik.setFieldValue(
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
val
);
};
const addExpenseItemHandler = (kandangExpenseIdx: number) => {
const newExpensesValue = [
...formik.values.kandangExpenses[kandangExpenseIdx].expenses,
{
nonstock: undefined,
totalExpense: undefined,
totalQuantity: undefined,
notes: '',
},
];
formik.setFieldValue(
`kandangExpenses[${kandangExpenseIdx}].expenses`,
newExpensesValue
);
};
const deleteExpenseItemHandler = (
kandangExpenseIdx: number,
expenseIdx: number
) => {
const path = `kandangExpenses[${kandangExpenseIdx}].expenses`;
// trims values, errors, and touched at expenseIdx
removeArrayItemAndSync(formik, path, expenseIdx);
};
const isExpenseRepeaterInputError = (
column: 'nonstock' | 'totalQuantity' | 'totalExpense' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
) => {
return (
formik.touched.kandangExpenses?.[kandangExpenseIdx]?.expenses?.[
expenseIdx
]?.[column] &&
Boolean(
formik.errors.kandangExpenses?.[kandangExpenseIdx] instanceof Object &&
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
expenseIdx
] instanceof Object &&
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
expenseIdx
]?.[column]
)
);
};
return (
<Card
className={{
wrapper: cn('w-full', className?.wrapper),
body: 'p-4 shadow',
}}
>
<div className='mb-4 text-center'>
<h4 className='font-bold text-xl'>
Rincian Pengajuan Biaya Operasional
</h4>
</div>
<div className='w-full flex flex-col gap-6'>
{formik.values.kandangExpenses.length === 0 && (
<div>
<p className='text-sm text-gray-400 text-center'>
Pilih kandang terlebih dahulu!
</p>
</div>
)}
{formik.values.kandangExpenses.map(
(kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandangId
);
return (
kandangName?.name && (
<div
key={`kandangExpense-${kandangExpenseIdx}`}
className='w-full flex flex-col gap-4'
>
<div>
<h5 className='mb-2 text-lg font-bold text-center'>
Biaya {kandangName?.name}
</h5>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
</thead>
<tbody>
{kandangExpense.expenses.map(
(expenseItem, expenseIdx) => (
<tr key={`expense-${expenseIdx}`}>
<td className='p-2'>
<SelectInput
placeholder='Pilih Nonstock'
value={expenseItem.nonstock}
onChange={(val) => {
nonstockChangeHandler(
kandangExpenseIdx,
expenseIdx,
val
);
}}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }}
/>
</td>
<td className='p-2'>
<NumberInput
required
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalQuantity`}
placeholder='Masukkan Total Kuantitas'
value={
formik.values.kandangExpenses[
kandangExpenseIdx
].expenses[expenseIdx].totalQuantity ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'totalQuantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<NumberInput
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalExpense`}
placeholder='Masukkan Total Biaya'
value={
formik.values.kandangExpenses[
kandangExpenseIdx
].expenses[expenseIdx].totalExpense ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'totalExpense',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
</span>
}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<TextInput
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].notes`}
placeholder='Tuliskan catatan'
value={
formik.values.kandangExpenses[
kandangExpenseIdx
].expenses[expenseIdx].notes ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() =>
deleteExpenseItemHandler(
kandangExpenseIdx,
expenseIdx
)
}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
)
)}
</tbody>
</table>
</div>
</div>
{type !== 'detail' && (
<Button
type='button'
variant='outline'
color='success'
onClick={() => addExpenseItemHandler(kandangExpenseIdx)}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
Tambah
</Button>
)}
</div>
)
);
}
)}
</div>
</Card>
);
};
export default ExpenseRequestKandangDetailExpense;
@@ -71,9 +71,8 @@ const InventoryAdjustmentForm = ({
Partial<InventoryAdjustmentFormValues> Partial<InventoryAdjustmentFormValues>
>(() => { >(() => {
return { return {
product_category_id: initialValues?.product_category?.id ?? 0, product_id: initialValues?.product_warehouse?.product_id ?? 0,
product_id: initialValues?.product?.id ?? 0, warehouse_id: initialValues?.product_warehouse?.warehouse_id ?? 0,
warehouse_id: initialValues?.warehouse?.id ?? 0,
product_category: undefined, product_category: undefined,
product: undefined, product: undefined,
warehouse: undefined, warehouse: undefined,
@@ -1,24 +1,46 @@
'use client'; 'use client';
import { useState } from 'react'; import { ChangeEventHandler, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { SortingState } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { useModal } from '@/components/Modal'; import { Icon } from '@iconify/react';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
import { MovementApi } from '@/services/api/inventory'; import { MovementApi } from '@/services/api/inventory';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { TableToolbar } from '@/components/table/TableToolbar'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; import Button from '@/components/Button';
import { OptionType } from '@/components/input/SelectInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { TableRowOptions } from '@/components/table/TableRowOptions'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Movement, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/inventory/movement/detail/?movementId=${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 MovementTable = () => { const MovementTable = () => {
const { const {
@@ -28,30 +50,47 @@ const MovementTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit' }, search: '',
product: '',
warehouse: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
product: 'product_id',
warehouse: 'warehouse_id',
},
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const deleteModal = useModal();
const { const {
data: movements, setInputValue: setProductInputValue,
isLoading, options: productOptions,
mutate: refreshMovements, isLoadingOptions: isLoadingProductOptions,
} = useSWR( } = useSelect<Product>('/products', 'id', 'name');
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
} = useSelect<Warehouse>('/warehouses', 'id', 'name');
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
null
);
const [selectedWarehouse, setSelectedWarehouse] = useState<OptionType | null>(
null
);
const { data: movements, isLoading } = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`, `${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher MovementApi.getAllFetcher
); );
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
setPage(1);
}; };
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -60,41 +99,17 @@ const MovementTable = () => {
setPage(1); setPage(1);
}; };
const confirmationModalDeleteClickHandler = async () => { const productChangeHandler = (val: OptionType | OptionType[] | null) => {
setIsDeleteLoading(true); setSelectedProduct(val as OptionType);
try { updateFilter('product', val ? ((val as OptionType).value as string) : '');
await MovementApi.delete(selectedMovement?.id as number);
refreshMovements();
deleteModal.closeModal();
} finally {
setIsDeleteLoading(false);
}
}; };
return ( const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
<div className='flex flex-col gap-4'> setSelectedWarehouse(val as OptionType);
<div className='flex flex-col gap-2 mb-4'> updateFilter('warehouse', val ? ((val as OptionType).value as string) : '');
<TableToolbar };
addButton={{
href: '/inventory/movement/add',
label: 'Tambah',
}}
search={{
value: tableFilterState.search,
onChange: searchChangeHandler,
placeholder: 'Cari Movement',
}}
/>
<TableRowSizeSelector
value={tableFilterState.pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
</div>
<Table<Movement> const movementColumns: ColumnDef<Movement>[] = [
data={isResponseSuccess(movements) ? movements?.data : []}
columns={[
{ {
header: '#', header: '#',
cell: (props) => cell: (props) =>
@@ -118,9 +133,7 @@ const MovementTable = () => {
accessorKey: 'transfer_date', accessorKey: 'transfer_date',
header: 'Tanggal', header: 'Tanggal',
cell: (props) => cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString( new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'),
'id-ID'
),
}, },
{ {
accessorFn: (row) => { accessorFn: (row) => {
@@ -135,52 +148,104 @@ const MovementTable = () => {
{ {
header: 'Aksi', header: 'Aksi',
cell: (props) => { cell: (props) => {
const currentPageSize = const currentPageSize = props.table.getPaginationRowModel().rows.length;
props.table.getPaginationRowModel().rows.length; const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentPageRows =
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 = currentRowRelativeIndex > currentPageSize - 2; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedMovement(props.row.original);
deleteModal.openModal();
};
return ( return (
<> <>
{currentPageSize > 2 && ( {currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}> <RowDropdownOptions isLast2Rows={isLast2Rows}>
<TableRowOptions <RowOptionsMenu type='dropdown' props={props} />
type='dropdown'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowDropdownOptions> </RowDropdownOptions>
)} )}
{currentPageSize <= 2 && ( {currentPageSize <= 2 && (
<RowCollapseOptions> <RowCollapseOptions>
<TableRowOptions <RowOptionsMenu type='collapse' props={props} />
type='collapse'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowCollapseOptions> </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'>
<Button
href='/inventory/movement/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Movement'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-4'>
<SelectInput
label='Produk'
options={productOptions}
isLoading={isLoadingProductOptions}
value={selectedProduct}
onChange={productChangeHandler}
onInputChange={setProductInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<SelectInput
label='Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={selectedWarehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<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<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={movementColumns}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0} page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={ totalItems={
@@ -205,22 +270,8 @@ const MovementTable = () => {
'px-6 py-3 last:flex last:flex-row last:justify-end', 'px-6 py-3 last:flex last:flex-row last:justify-end',
}} }}
/> />
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</div> </div>
</>
); );
}; };
@@ -1,34 +1,82 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
export type ProductSchema = { type MovementFormSchemaType = {
product: { transfer_reason: string;
transfer_date: string;
source_warehouse?: {
value: number;
label: string;
area?: string;
location?: string;
} | null;
source_warehouse_id: number;
destination_warehouse?: {
value: number;
label: string;
area?: string;
location?: string;
} | null;
destination_warehouse_id: number;
products: {
product?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
product_id: number; product_id: number;
product_qty: number; product_qty: number | string;
}; }[];
deliveries: {
export type DeliverySchema = { delivery_cost?: number | string;
delivery_cost?: number | undefined; delivery_cost_per_item?: number | string;
delivery_cost_per_item?: number | undefined;
document?: File | string | null; document?: File | string | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name: string;
vehicle_plate: string; vehicle_plate: string;
supplier: { supplier?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
supplier_id: number; supplier_id: number;
products: { products: {
product: { product?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
product_id: number; product_id: number;
product_qty: number; product_qty: number | string;
}[];
}[];
};
export type ProductSchema = {
product?: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number | string;
};
export type DeliverySchema = {
delivery_cost?: number | string;
delivery_cost_per_item?: number | string;
document?: File | string | null;
document_path?: string | null;
driver_name: string;
vehicle_plate: string;
supplier?: {
value: number;
label: string;
} | null;
supplier_id: number;
products: {
product?: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number | string;
}[]; }[];
}; };
@@ -102,7 +150,8 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
.required('Produk wajib diisi!'), .required('Produk wajib diisi!'),
}); });
export const MovementFormSchema = Yup.object({ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
Yup.object({
transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'),
transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'),
source_warehouse: Yup.object({ source_warehouse: Yup.object({
@@ -133,8 +182,6 @@ export const MovementFormSchema = Yup.object({
.required('Pengiriman wajib diisi!'), .required('Pengiriman wajib diisi!'),
}); });
export const UpdateMovementFormSchema = MovementFormSchema;
export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>; export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>;
export const getMovementFormInitialValues = ( export const getMovementFormInitialValues = (
File diff suppressed because it is too large Load Diff
@@ -1,95 +0,0 @@
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { useModal } from '@/components/Modal';
import { MovementApi } from '@/services/api/inventory';
import {
CreateMovementPayload,
UpdateMovementPayload,
} from '@/types/api/inventory/movement';
import { isResponseError } from '@/lib/api-helper';
export const useMovementFormHandlers = (initialValuesId?: number) => {
const router = useRouter();
const deleteModal = useModal();
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createMovementHandler = useCallback(
async (payload: CreateMovementPayload, documents: File[] = []) => {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
const res = await MovementApi.create(
formData as unknown as CreateMovementPayload
);
if (isResponseError(res)) {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/inventory/movement');
},
[router]
);
const updateMovementHandler = useCallback(
async (
movementId: number,
payload: UpdateMovementPayload,
documents: File[] = []
) => {
let finalPayload: UpdateMovementPayload | FormData;
if (documents.length > 0) {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
finalPayload = formData as unknown as UpdateMovementPayload;
} else {
finalPayload = payload;
}
const res = await MovementApi.update(movementId, finalPayload);
if (res?.status === 'error') {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/inventory/movement');
},
[router]
);
const deleteMovementClickHandler = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const confirmationModalDeleteClickHandler = useCallback(async () => {
if (!initialValuesId) return;
setIsDeleteLoading(true);
await MovementApi.delete(initialValuesId);
deleteModal.closeModal();
toast.success('Successfully delete Movement!');
setIsDeleteLoading(false);
router.push('/inventory/movement');
}, [deleteModal, initialValuesId, router]);
return {
deleteModal,
movementFormErrorMessage,
isDeleteLoading,
createMovementHandler,
updateMovementHandler,
deleteMovementClickHandler,
confirmationModalDeleteClickHandler,
};
};
@@ -0,0 +1,625 @@
'use client';
import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseSalesOrder, Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { CellContext, Row } from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const RowsOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
deliveryClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Marketing, unknown>;
deleteClickHandler: () => void;
deliveryClickHandler?: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<div className='flex flex-col gap-1'>
<Button
href={`/marketing/detail?marketingId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
{props.row.original.latest_approval.step_number != 1 && (
<Button
href={
props.row.original.latest_approval.step_number == 3
? `/marketing/detail/delivery-orders/edit?marketingId=${props.row.original.id}`
: props.row.original.latest_approval.step_number == 2
? `/marketing/add/delivery-orders?marketingId=${props.row.original.id}`
: undefined
}
onClick={() => {
if (props.row.original.latest_approval.step_number == 2) {
deliveryClickHandler?.();
}
}}
variant='ghost'
color='success'
className='justify-start text-sm'
>
<Icon icon='mdi:truck' width={16} height={16} />
Deliver
</Button>
)}
{props.row.original.latest_approval.step_number != 3 && (
<Button
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
)}
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit justify-start text-sm'
>
<Icon icon='mdi:delete-outline' width={16} height={16} />
Delete
</Button>
</div>
</div>
);
};
const MarketingTable = () => {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [approveAction, setApproveAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const router = useRouter();
const {
data: marketing,
isLoading: isLoadingMarketing,
mutate: refreshMarketing,
} = useSWR(MarketingApi.basePath, MarketingApi.getAllFetcher);
const deleteModal = useModal();
const confirmationModal = useModal();
const productsModal = useModal();
const deliveryModal = useModal();
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setPage(1);
},
[]
);
const pageSizeChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
},
[]
);
const approveClickHandler = () => {
setApproveAction('APPROVED');
confirmationModal.openModal();
};
const rejectClickHandler = () => {
setApproveAction('REJECTED');
confirmationModal.openModal();
};
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete(
selectedItem?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
confirmationModal.closeModal();
toast.success(deleteMarketingRes?.message as string);
}
if (isResponseError(deleteMarketingRes)) {
confirmationModal.closeModal();
toast.error(deleteMarketingRes?.message as string);
}
refreshMarketing();
deleteModal.closeModal();
};
const allData = isResponseSuccess(marketing) ? marketing.data : [];
const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()]
);
const hasApprovable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const hasRejectable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const disableApprove = !hasApprovable;
const disableReject = !hasRejectable;
const idsToProcess =
approveAction === 'APPROVED'
? selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id)
: selectedRowsData
.filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
const approveMarketingHandler = async (notes: string) => {
let idsToProcess: number[] = [];
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal();
return;
}
const approveMarketingRes = await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string);
setRowSelection({});
}
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
};
const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
deliveryModal.closeModal();
toast.success(res?.message as string);
refreshMarketing?.();
router.push(
`/marketing/detail/delivery-orders/edit?marketingId=${selectedItem?.id}`
);
};
const {
state: tableFilterState,
updateFilter,
toQueryString: getTableFilterToQueryString,
} = useTableFilter({
initial: {
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
};
return (
<>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2 mb-4'>
<TableToolbar
addButton={{
href: '/marketing/add/sales-orders',
label: 'Tambah Sales Order',
}}
search={{
value: search,
onChange: searchChangeHandler,
placeholder: 'Cari Sales Order',
}}
/>
<div className='flex flex-row gap-2'>
<Button
color='success'
onClick={approveClickHandler}
className='justify-start text-sm'
disabled={disableApprove}
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
<Button
color='error'
onClick={rejectClickHandler}
className='justify-start text-sm'
disabled={disableReject}
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
</div>
<TableRowSizeSelector
value={pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
>
{/* select multiple product */}
<SelectInput
label='Product'
isClearable
placeholder='Pilih product'
options={[]}
isMulti
/>
{/* select status */}
<SelectInput
label='Status'
isClearable
placeholder='Pilih status'
options={[]}
/>
{/* select customer */}
<SelectInput
label='Customer'
isClearable
placeholder='Pilih customer'
options={[]}
/>
</TableRowSizeSelector>
</div>
<Table
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={allData}
columns={[
{
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(getRowCanSelect);
const allSelected =
selectableRows.length > 0 &&
selectableRows.every((row) => row.getIsSelected());
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
);
};
return (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={selectableRows.length === 0}
/>
</div>
);
},
cell: ({ row }) => {
const canSelect = getRowCanSelect(row);
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!canSelect}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{
accessorKey: 'so_number',
header: 'No. Order',
},
{
accessorKey: 'so_date',
header: 'Tanggal',
cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
},
},
{
accessorKey: 'latest_approval.step_name',
header: 'Status',
},
{
accessorKey: 'customer.name',
header: 'Customer',
},
{
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total',
cell: (props) => {
return formatCurrency(
props.row.original?.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0
);
},
},
{
accessorKey: 'marketing_products.length',
header: 'Product Details',
cell: (props) => {
if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) {
return (
<Button
variant='link'
color='success'
className='p-0 text-none'
onClick={() => {
productsClickHandler(props?.row?.original);
}}
>
Lihat {props?.row?.original?.sales_order?.length} Produk
</Button>
);
} else {
const product = props?.row?.original?.sales_order[0];
return <>{product?.product_warehouse?.product?.name}</>;
}
}
},
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows =
props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows =
currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedItem(props.row.original);
deleteModal.openModal();
};
const deliveryClickHandler = () => {
setSelectedItem(props.row.original);
deliveryModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowsOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowsOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
]}
pageSize={pageSize}
page={page}
onPageChange={setPage}
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-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>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
}}
/>
<ConfirmationModalWithNotes
ref={confirmationModal.ref}
type={approveAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan (${idsToProcess.length} data)?`}
secondaryButton={{
text: 'Tidak',
onClick: confirmationModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: approveAction === 'APPROVED' ? 'success' : 'error',
onClick: approveMarketingHandler,
}}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
onClick: deleteModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: deleteMarketingHandler,
}}
/>
<ConfirmationModalWithNotes
ref={deliveryModal.ref}
type={'success'}
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
onClick: confirmationModalDeliveryClickHandler,
}}
/>
<Modal
ref={productsModal.ref}
className={{
modalBox: 'max-w-2/5 z-100',
}}
closeOnBackdrop
>
<div className='flex flex-row justify-between items-center mb-3'>
<h4 className='text-xl font-semibold'>Daftar Produk</h4>
<Button
variant='ghost'
color='error'
onClick={productsModal.closeModal}
className='justify-start text-sm rounded-full'
>
<Icon icon='mdi:close' width={16} height={16} />
</Button>
</div>
<Table<BaseSalesOrder>
data={
isResponseSuccess(marketing) && selectedItem
? (selectedItem?.sales_order ?? [])
: []
}
columns={[
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
]}
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-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',
}}
/>
</Modal>
</>
);
};
export default MarketingTable;
@@ -0,0 +1,477 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import Table from '@/components/Table';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import {
BaseDelivery,
BaseSalesOrder,
Marketing,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
const MarketingDetail = ({
initialValues,
refresh,
}: {
initialValues?: Marketing;
refresh?: () => void;
}) => {
const router = useRouter();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
const [grandTotal, setGrandTotal] = useState(
initialValues?.sales_order
?.map((item) => item.total_price)
.reduce((a, b) => a + b, 0)
);
const [isLoading, setIsLoading] = useState(false);
const deleteModal = useModal();
const confirmationModal = useModal();
const deliveryModal = useModal();
const {
approvals,
isLoading: isLoadingApproval,
refresh: refreshApproval,
} = useApprovalSteps({
latestApproval: initialValues?.latest_approval,
approvalLines: MARKETING_APPROVAL_LINE,
moduleName: 'MARKETINGS',
moduleId: initialValues?.id as number as unknown as string,
});
const approveClickHandler = () => {
setApprovalAction('APPROVED');
confirmationModal.openModal();
};
const rejectClickHandler = () => {
setApprovalAction('REJECTED');
confirmationModal.openModal();
};
const deliveryClickHandler = () => {
deliveryModal.openModal();
};
const deleteClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsLoading(true);
const res = await MarketingApi.delete(initialValues?.id as number);
deleteModal.closeModal();
router.push('/marketing');
toast.success(res?.message as string);
refresh?.();
setIsLoading(false);
};
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsLoading(true);
const res = await SalesOrderApi.singleApproval(
initialValues?.id as number,
approvalAction,
notes
);
setIsLoading(false);
confirmationModal.closeModal();
toast.success(res?.message as string);
refresh?.();
refreshApproval?.();
};
const confirmationModalDeliveryClickHandler = async (notes: string) => {
setIsLoading(true);
const res = await SalesOrderApi.delivery(
initialValues?.id as number,
notes
);
setIsLoading(false);
deliveryModal.closeModal();
toast.success(res?.message as string);
refresh?.();
refreshApproval?.();
router.push(
`/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
);
};
return (
<>
<div className='flex flex-col w-full gap-4'>
<FormHeader title='Detail Sales Order' backUrl='/marketing' />
{!isLoadingApproval && approvals && (
<ApprovalSteps approvals={approvals} />
)}
<div className='flex-row flex gap-3'>
{initialValues?.latest_approval?.step_number == 1 && (
<>
<Button
color='success'
onClick={approveClickHandler}
disabled={
initialValues?.latest_approval?.step_number == 1 &&
initialValues?.latest_approval?.action == 'REJECTED'
}
>
<Icon icon='mdi:check' width={24} height={24} />
Approve
</Button>
<Button
color='error'
onClick={rejectClickHandler}
disabled={
initialValues?.latest_approval?.step_number == 1 &&
initialValues?.latest_approval?.action == 'REJECTED'
}
>
<Icon icon='mdi:close' width={24} height={24} />
Reject
</Button>
</>
)}
{initialValues?.latest_approval?.step_number != 1 && (
<Button
color='success'
href={
initialValues?.latest_approval?.step_number == 3
? `/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
: `/marketing/add/delivery-orders?marketingId=${initialValues?.id}`
}
>
<Icon icon='mdi:truck' width={24} height={24} />
{initialValues?.latest_approval?.step_number == 3
? 'Edit '
: 'Tambah '}
Delivery Order
</Button>
)}
</div>
<Card
title='Informasi Penjualan'
className={{
wrapper: 'w-full bg-white',
}}
>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td width='45%' className='font-semibold'>
No. Sales Order
</td>
<td>:</td>
<td width='50%'>{initialValues?.so_number}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Pelanggan</td>
<td>:</td>
<td>{initialValues?.customer?.name}</td>
</tr>
<tr>
<td className='font-semibold'>Status</td>
<td>:</td>
<td>{initialValues?.latest_approval?.step_name}</td>
</tr>
<tr>
<td className='font-semibold'>Tanggal Penjualan</td>
<td>:</td>
<td>{formatDate(initialValues?.so_date, 'DD MMM yyyy')}</td>
</tr>
<tr>
<td className='font-semibold'>Total Penjualan</td>
<td>:</td>
<td>{formatCurrency(grandTotal as number)}</td>
</tr>
<tr>
<td className='font-semibold'>Catatan</td>
<td>:</td>
<td>{initialValues?.notes ?? '-'}</td>
</tr>
<tr>
<td className='font-semibold'>Dokumen</td>
<td>:</td>
<td>
<SalesOrderExport data={initialValues} />
</td>
</tr>
</tbody>
</table>
</div>
</Card>
{initialValues?.sales_order && (
<Card
title='Informasi Produk'
className={{
wrapper: 'w-full bg-white',
}}
>
<Table<BaseSalesOrder>
data={initialValues?.sales_order}
columns={[
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
{
header: 'Total Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.total_weight);
},
},
{
header: 'Kuantitas',
accessorFn(row) {
return formatNumber(row.qty);
},
},
{
header: 'Avg. Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.avg_weight);
},
},
{
header: 'Total Penjualan (Rp)',
accessorFn(row) {
return formatCurrency(row.total_price);
},
},
]}
className={{
containerClassName: cn({
'mb-20':
initialValues?.sales_order &&
initialValues?.sales_order?.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',
}}
/>
</Card>
)}
{initialValues?.delivery_order && (
<Card
title='Informasi Pengiriman'
className={{
wrapper: 'w-full bg-white',
}}
>
{initialValues?.delivery_order.map((delivery, index) => {
return (
<div key={index}>
<Card
className={{
wrapper: 'w-full',
}}
>
<div className='flex flex-row gap-3'>
<div className='font-semibold'>
Nomor DO : {delivery.do_number}
</div>
</div>
<Table<BaseDelivery>
data={delivery.deliveries}
columns={[
{
header: 'Tanggal Pengiriman',
accessorFn() {
return formatDate(
delivery.delivery_date,
'DD MMM yyyy'
);
},
},
{
header: 'No. Polisi',
accessorFn(row) {
return formatVechicleNumber(row.vehicle_number);
},
},
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
{
header: 'Total Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.total_weight);
},
},
{
header: 'Kuantitas',
accessorFn(row) {
return formatNumber(row.qty);
},
},
{
header: 'Avg. Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.avg_weight);
},
},
{
header: 'Total Penjualan (Rp)',
accessorFn(row) {
return formatCurrency(row.total_price);
},
},
]}
className={{
containerClassName: cn({
'mb-20':
initialValues?.sales_order &&
initialValues?.sales_order?.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',
}}
/>
</Card>
<div className='flex flex-row gap-3 my-3'>
<DeliveryOrderExport
data={initialValues}
deliveryOrder={delivery}
/>
</div>
</div>
);
})}
</Card>
)}
<div className='flex flex-row gap-3'>
{initialValues?.latest_approval?.step_number != 3 && (
<Button
color='warning'
type='button'
href={`/marketing/detail/${initialValues?.latest_approval.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
>
<Icon icon='mdi:pencil' width={24} height={24} />
Edit
</Button>
)}
<Button color='error' onClick={deleteClickHandler}>
<Icon icon='mdi:delete' width={24} height={24} />
Hapus
</Button>
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={confirmationModal.ref}
type={approvalAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approvalAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction === 'APPROVED' ? 'success' : 'error',
isLoading: isLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={deliveryModal.ref}
type={'success'}
text={`Apakah anda yakin ingin deliver penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isLoading,
onClick: confirmationModalDeliveryClickHandler,
}}
/>
</>
);
};
export default MarketingDetail;
@@ -0,0 +1,77 @@
import * as Yup from 'yup';
import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './repeater/delivery-order/DeliverOrderProduct.schema';
type MarketingSchemaType = {
customer_id: number | undefined;
sales_person_id: number | undefined;
customer:
| {
value: number;
label: string;
}
| undefined
| null;
so_date: string | undefined;
notes: string | undefined;
};
type SalesOrderSchemaType = MarketingSchemaType & {
sales_order: SalesOrderProductFormValues[];
};
type DeliveryOrderSchemaType = {
delivery_order: DeliveryOrderProductFormValues[];
};
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'),
sales_person_id: Yup.number().required('Sales Person wajib diisi!'),
customer: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
so_date: Yup.string().required('Tanggal wajib diisi!'),
notes: Yup.string().required('Catatan wajib diisi!'),
sales_order: Yup.array()
.of(SalesOrderProductSchema)
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
});
export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
Yup.object({
delivery_order: Yup.array()
.min(1, 'Pengiriman wajib diisi!')
.required('Pengiriman wajib diisi!')
.test(
'at-least-one-valid-row',
'Minimal ada satu baris pengiriman yang valid!',
function (items) {
if (!items || items.length === 0) return false;
// VALIDASI: minimal 1 item valid full
const itemSchema = DeliveryOrderProductSchema;
const hasValidItem = items.some((item) => {
if (!item) return false;
return itemSchema.isValidSync(item, { abortEarly: true });
});
return hasValidItem;
}
),
});
export const UpdateSalesOrderSchema = SalesOrderSchema;
export type SalesOrderFormValues = Yup.InferType<typeof SalesOrderSchema>;
export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
@@ -0,0 +1,802 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import { formatCurrency, formatDate } from '@/lib/helper';
import {
BaseDeliveryOrder,
BaseSalesOrder,
CreateDeliveryOrderPayload,
CreateSalesOrderPayload,
CreateSalesOrderProductPayload,
Marketing,
UpdateDeliveryOrderPayload,
UpdateSalesOrderPayload,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { useFormik } from 'formik';
import {
DeliveryOrderFormValues,
DeliveryOrderSchema,
SalesOrderFormValues,
SalesOrderSchema,
} from './MarketingForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
DeliveryOrderApi,
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { SalesOrderProductFormValues } from './repeater/sales-order/SalesOrderProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
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';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
const MemoizedDeliveryOrderProductTable = memo(DeliveryOrderProductTable);
const MemoizedDeliveryOrderProductForm = memo(DeliveryOrderProductForm);
// ================== EXTERNAL HELPER FUNCTION ==================
export interface ProductCalculationFields {
qty: string | number | undefined;
unit_price: string | number | undefined;
total_price: string | number | undefined;
avg_weight: string | number | undefined;
total_weight: string | number | undefined;
}
export const SalesProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
return {
id: product.id,
vehicle_number: product.vehicle_number,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
avg_weight: product.avg_weight,
total_price: product.total_price,
};
};
export const DeliveryProductToFieldValues = (
salesOrders: BaseSalesOrder[],
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => {
const soId = salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
)?.id;
return {
id: soId,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
avg_weight: item.avg_weight,
total_price: item.total_price,
vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number,
marketing_product_id: soId,
marketing_product: {
id: soId,
vehicle_number: item.vehicle_number,
kandang_id: item.product_warehouse.warehouse.id,
kandang: {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
},
product_warehouse: {
value: item.product_warehouse.id,
label: item.product_warehouse.product.name,
},
product_warehouse_id: item.product_warehouse.id,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
avg_weight: item.avg_weight,
total_price: item.total_price,
},
} as DeliveryOrderProductFormValues;
});
return data;
};
export const mergeSOwithDO = (
salesOrders: SalesOrderProductFormValues[],
deliveryOrders: DeliveryOrderProductFormValues[]
): DeliveryOrderProductFormValues[] => {
return salesOrders.map((so) => {
const delivery = deliveryOrders.find(
(d) => d?.marketing_product_id === so.id
);
return {
...so, // nilai dasar dari sales order
marketing_product_id: so.id,
delivery_date: delivery?.delivery_date || undefined,
do_number: delivery?.do_number || undefined,
vehicle_number: delivery?.vehicle_number || so.vehicle_number,
unit_price: delivery?.unit_price,
total_weight: delivery?.total_weight,
qty: delivery?.qty,
avg_weight: delivery?.avg_weight,
total_price: delivery?.total_price,
marketing_product: so, // jika ada, override
} as DeliveryOrderProductFormValues;
});
};
export const recalculate = (
field: string,
values: ProductCalculationFields
) => {
console.log('Values');
console.log(values);
const { qty, unit_price, total_price, avg_weight, total_weight } = values;
const result: Partial<ProductCalculationFields> = {};
if (field == 'unit_price' || field == 'total_price' || field == 'qty') {
if (qty && unit_price && (field == 'unit_price' || field == 'qty')) {
result.total_price = Number(qty) * Number(unit_price);
} else if (qty && total_price && field == 'total_price') {
result.unit_price = Number(total_price) / Number(qty);
}
}
if (field == 'avg_weight' || field == 'total_weight' || field == 'qty') {
if (qty && avg_weight && (field == 'avg_weight' || field == 'qty')) {
result.total_weight = Number(qty) * Number(avg_weight);
} else if (qty && total_weight && field == 'total_weight') {
result.avg_weight = Number(total_weight) / Number(qty);
}
}
console.log('Result');
console.log(result);
return result;
};
export const getSubmitField = (values: ProductCalculationFields) => {
const { qty, unit_price, total_price, avg_weight, total_weight } = values;
// Harga logic
if (qty && unit_price && !total_price) {
return 'unit_price';
}
if (qty && total_price && !unit_price) {
return 'total_price';
}
// Bobot logic
if (qty && avg_weight && !total_weight) {
return 'avg_weight';
}
if (qty && total_weight && !avg_weight) {
return 'total_weight';
}
// Tidak ada yang perlu dihitung
return '';
};
const MarketingForm = ({
formType = 'add',
initialValues,
afterSubmit,
}: {
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
initialValues?: Marketing;
afterSubmit?: () => void;
}) => {
const router = useRouter();
const deleteModal = useModal();
const [isLoading, setIsLoading] = useState(false);
const [selectedMarketingProduct, setSelectedMarketingProduct] =
useState<SalesOrderProductFormValues | null>(null);
const [selectedDeliveryProduct, setSelectedDeliveryProduct] =
useState<DeliveryOrderProductFormValues | null>(null);
const [deliveryFormState, setDeliveryFormState] = useState<'add' | 'edit'>(
'add'
);
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[]
>(
mergeSOwithDO(
initialValues?.sales_order?.map(SalesProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
)
);
// ================== REPEATER ==================
const addSOModal = useModal();
const addDOModal = useModal();
const [rowSOSelection, setRowSOSelection] = useState<Record<string, boolean>>(
{}
);
const selectedRowSOIds = Object.keys(rowSOSelection).map((item) =>
parseInt(item)
);
// ================== FETCH OPTIONS ==================
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
// ================== SETUP FORMIK ==================
const formikInitialValues = useMemo<
SalesOrderFormValues & DeliveryOrderFormValues
>(() => {
return {
so_date: initialValues?.so_date || undefined,
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || 1,
customer: initialValues?.customer
? {
value: initialValues.customer.id,
label: initialValues.customer.name,
}
: null,
sales_order:
initialValues?.sales_order?.map((product) =>
SalesProductToFieldValues(product)
) ?? [],
delivery_order: mergeSOwithDO(
initialValues?.sales_order?.map(SalesProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
),
};
}, [initialValues]);
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
enableReinitialize: true,
initialValues: formikInitialValues,
validationSchema:
formType == 'add_deliver' || formType == 'edit_deliver'
? DeliveryOrderSchema
: SalesOrderSchema,
validateOnMount: true,
onSubmit: async (values) => {
const payload =
formType != 'add_deliver' && formType != 'edit_deliver'
? ({
customer_id: values.customer_id as number,
sales_person_id: values.sales_person_id as number,
date: formatDate(values.so_date as string, 'yyyy-MM-DD'),
notes: values.notes as string,
marketing_products: values.sales_order.map((product) => {
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
} as CreateSalesOrderProductPayload;
}),
} as CreateSalesOrderPayload)
: ({
marketing_id: initialValues?.id as number,
delivery_products: values.delivery_order
.map((product) => {
if (Boolean(product.delivery_date)) {
return {
marketing_product_id:
product.marketing_product_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
delivery_date: formatDate(
product.delivery_date as string,
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
};
}
})
.filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload);
console.log('PAYLOAD');
console.log(payload);
switch (formType) {
case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload);
break;
case 'edit':
await updateMarketingHandler(payload as UpdateSalesOrderPayload);
break;
case 'add_deliver':
await createDeliveryHandler(payload as CreateDeliveryOrderPayload);
break;
case 'edit_deliver':
await updateDeliveryHandler(payload as UpdateDeliveryOrderPayload);
break;
default:
break;
}
afterSubmit?.();
},
});
// ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const createMarketingRes = await SalesOrderApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
toast.success(createMarketingRes?.message as string);
router.push('/marketing');
}
if (isResponseError(createMarketingRes)) {
toast.error(createMarketingRes?.message as string);
}
setIsLoading(false);
};
const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const updateMarketingRes = await SalesOrderApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(updateMarketingRes)) {
toast.success(updateMarketingRes?.message as string);
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(updateMarketingRes)) {
toast.error(updateMarketingRes?.message as string);
}
setIsLoading(false);
};
const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
const createDeliveryRes = await DeliveryOrderApi.create(values);
if (isResponseSuccess(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.success(createDeliveryRes?.message as string);
setDeliveryOrderValues(
createDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(
createDeliveryRes.data?.sales_order,
delivery
)
) ?? []
);
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.error(createDeliveryRes?.message as string);
}
setIsLoading(false);
};
const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
const updateDeliveryRes = await DeliveryOrderApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.success(updateDeliveryRes?.message as string);
setDeliveryOrderValues(
mergeSOwithDO(
formik.values.sales_order,
updateDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(
updateDeliveryRes.data?.sales_order,
delivery
)
) ?? []
)
);
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.error(updateDeliveryRes?.message as string);
}
setIsLoading(false);
};
// ================== MARKETING HANDLER ==================
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.success(deleteMarketingRes?.message as string);
}
if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.error(deleteMarketingRes?.message as string);
}
setIsLoading(false);
deleteModal.closeModal();
router.push('/marketing');
};
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[]
);
const handleDelete = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
// ================== SALES ORDER HANDLER ==================
const handleDeleteSO = useCallback((id: number) => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter((p) => p.id != id)
);
}, []);
const handleBulkDeleteSO = useCallback(() => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter(
(product) => !selectedRowSOIds.includes(product.id ?? -1)
)
);
setRowSOSelection({});
}, [selectedRowSOIds]);
const handleAddSOClick = useCallback(() => {
setSelectedMarketingProduct(null);
addSOModal.openModal();
}, [addSOModal]);
const handleAddSubmitSO = useCallback(
async (values: SalesOrderProductFormValues) => {
const currentProducts = formik.values.sales_order;
const newValues = {
...values,
id: values.id ?? Date.now(),
};
const existingIndex = currentProducts.findIndex(
(item) =>
item.kandang_id === newValues.kandang_id &&
item.product_warehouse_id === newValues.product_warehouse_id
);
let updatedProducts = [];
if (existingIndex !== -1) {
// Overwrite
updatedProducts = currentProducts.map((item, index) =>
index === existingIndex ? newValues : item
);
} else {
// Add new item
updatedProducts = [...currentProducts, newValues];
}
formik.setFieldValue('sales_order', updatedProducts);
addSOModal.closeModal();
},
[addSOModal]
);
// ================== DELIVERY ORDER HANDLER ==================
const handleEditDO = useCallback(
(id: number, values?: DeliveryOrderProductFormValues) => {
setDeliveryFormState('edit');
const currentProducts = formik.values.delivery_order.find(
(product) => product.id == id
);
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
addDOModal.openModal();
},
[addDOModal]
);
const handleAddDOClick = useCallback(() => {
setDeliveryFormState('add');
setSelectedDeliveryProduct(null);
addDOModal.openModal();
}, [addDOModal]);
const handleAddSubmitDO = useCallback(
async (values: DeliveryOrderProductFormValues) => {
const newValues = {
...values,
id: values.id ?? Date.now(),
};
setDeliveryOrderValues((prev) => [...prev, newValues]);
addDOModal.closeModal();
setSelectedDeliveryProduct(null);
},
[addDOModal]
);
const handleUpdateDO = useCallback(
async (id: number, values: DeliveryOrderProductFormValues) => {
setDeliveryOrderValues((prev) =>
prev.map((product) =>
product.id === id ? { ...product, ...values } : product
)
);
addDOModal.closeModal();
setSelectedDeliveryProduct(null);
},
[addDOModal]
);
const memoSalesOrder = formik.values.sales_order;
useEffect(() => {
formik.setFieldValue('delivery_order', deliveryOrderValues);
}, [deliveryOrderValues, initialValues]);
const grandTotal = useMemo(() => {
return memoSalesOrder.reduce(
(total, product) =>
total + parseFloat((product.total_price as string) || '0'),
0
);
}, [memoSalesOrder]);
return (
<>
<form
className='flex flex-col gap-4'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
<FormHeader
title={`${formType == 'add' || formType == 'add_deliver' ? 'Tambah' : 'Edit'} ${formType === 'add_deliver' || formType === 'edit_deliver' ? 'Delivery' : 'Sales'} Order`}
backUrl='/marketing'
/>
{/* Input Cutomer And Date */}
<Card
title='Informasi Order'
className={{
wrapper: 'bg-white w-full',
}}
>
<div className='grid grid-cols-2 gap-3 mt-3'>
<SelectInput
label='Pelanggan'
options={customerOptions}
isLoading={isLoadingCustomerOptions}
value={formik.values.customer}
onChange={handleChangeCustomer}
isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id)
}
errorMessage={formik.errors.customer_id}
isClearable
placeholder='Pilih Pelanggan'
isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
<DateInput
name='so_date'
label='Tanggal'
value={formik.values.so_date}
onChange={formik.handleChange}
isError={formik.touched.so_date && Boolean(formik.errors.so_date)}
errorMessage={formik.errors.so_date}
placeholder='Pilih Tanggal'
readOnly={formType == 'add_deliver' || formType == 'edit_deliver'}
/>
</div>
</Card>
{/* Input Table Repeater Sales Order */}
<Card
title='Informasi Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
<MemoizedSalesOrderProductTable
formType={formType}
data={memoSalesOrder}
rowSelection={rowSOSelection}
setRowSelection={setRowSOSelection}
selectedRowIds={selectedRowSOIds}
onDelete={handleDeleteSO}
onBulkDelete={handleBulkDeleteSO}
onAddProductClick={handleAddSOClick}
/>
</Card>
{/* Input Table Repeater Delivery Order */}
{(formType == 'add_deliver' || formType == 'edit_deliver') &&
initialValues?.sales_order &&
initialValues?.sales_order.length > 0 && (
<Card
title='Informasi Pengiriman'
className={{
wrapper: 'bg-white w-full',
}}
>
{/* <div className='text-blue-500'>
{JSON.stringify(formik.values)}
</div>
<div className='text-red-500'>
{JSON.stringify(formik.errors)}
</div> */}
<MemoizedDeliveryOrderProductTable
formType={formType}
data={deliveryOrderValues}
onEdit={handleEditDO}
onAddProductClick={handleAddDOClick}
/>
</Card>
)}
{/* Input Notes */}
<div className='grid grid-cols-2 gap-3'>
<DebouncedTextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={formType === 'add_deliver' || formType === 'edit_deliver'}
/>
<div className='flex flex-col h-full justify-between items-end py-6'>
<span>Total Penjualan</span>
<span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}{' '}
</span>
</div>
</div>
{/* Form Actions */}
<div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
</Button>
<Button
type='submit'
disabled={!formik.isValid || formik.isSubmitting}
isLoading={formik.isSubmitting}
>
Submit
</Button>
</div>
</form>
{/* Actions button */}
{formType == 'edit' && (
<div className='flex flex-row justify-start'>
<Button
type='button'
color='error'
onClick={handleDelete}
isLoading={isLoading}
>
<Icon icon='mdi:trash' width={24} height={24} />
Hapus
</Button>
</div>
)}
{/* Modals */}
<Modal
ref={addSOModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
}}
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center justify-between'>
<h3 className='text-lg font-semibold mb-4'>Tambah Produk</h3>
<Button
variant='ghost'
color='error'
className='rounded-full'
onClick={addSOModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<MemoizedSalesOrderProductForm
onSubmitForm={handleAddSubmitSO}
initialValues={selectedMarketingProduct ?? undefined}
exisitingValues={memoSalesOrder}
/>
</div>
</div>
</Modal>
<Modal
ref={addDOModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
}}
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center justify-between'>
<h3 className='text-lg font-semibold mb-4'>
{selectedDeliveryProduct ? 'Edit' : 'Tambah'} Pengiriman
</h3>
<Button
variant='ghost'
color='error'
className='rounded-full'
onClick={addDOModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<MemoizedDeliveryOrderProductForm
formState={deliveryFormState}
salesOrders={initialValues?.sales_order ?? []}
exisitingValues={deliveryOrderValues}
onSubmitForm={handleAddSubmitDO}
initialValues={selectedDeliveryProduct ?? undefined}
onUpdateForm={handleUpdateDO}
/>
</div>
</div>
</Modal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
onClick: deleteModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: deleteMarketingHandler,
}}
/>
</>
);
};
export default MarketingForm;
@@ -0,0 +1,47 @@
import * as Yup from 'yup';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
type DeliveryOrderProductSchemaType = {
id?: number | undefined;
marketing_product_id: number | undefined; // Sales Order ID
marketing_product?: SalesOrderProductFormValues | undefined | null;
unit_price: string | number | undefined;
total_weight: string | number | undefined;
qty: string | number | undefined;
avg_weight: string | number | undefined;
total_price: string | number | undefined;
vehicle_number: string | undefined;
delivery_date: string | undefined | null;
do_number?: string | undefined | null; // Uncertain
};
export const DeliveryOrderProductSchema: Yup.ObjectSchema<DeliveryOrderProductSchemaType> =
Yup.object({
id: Yup.number(),
marketing_product_id: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
marketing_product: Yup.object().nullable().optional(),
unit_price: Yup.number()
.min(1, 'Harga Satuan wajib diisi!')
.required('Harga Satuan wajib diisi!'),
total_weight: Yup.number()
.min(0, 'Total Bobot wajib diisi!')
.required('Total Bobot wajib diisi!'),
qty: Yup.number()
.min(1, 'Kuantitas wajib diisi!')
.required('Kuantitas wajib diisi!'),
avg_weight: Yup.number()
.min(0, 'Avg. Bobot wajib diisi!')
.required('Avg. Bobot wajib diisi!'),
total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
delivery_date: Yup.string().required('Tanggal Pengiriman wajib diisi!'),
do_number: Yup.string().nullable().optional(),
});
export type DeliveryOrderProductFormValues = Yup.InferType<
typeof DeliveryOrderProductSchema
>;
@@ -0,0 +1,394 @@
import { useEffect, useState } from 'react';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './DeliverOrderProduct.schema';
import { useFormik } from 'formik';
import Alert from '@/components/Alert';
import Button from '@/components/Button';
import NumberInput from '@/components/input/NumberInput';
import PatternInput from '@/components/input/PatternInput';
import { formatVechicleNumber } from '@/lib/helper';
import DateInput from '@/components/input/DateInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { BaseSalesOrder } from '@/types/api/marketing/marketing';
import Badge from '@/components/Badge';
import { SalesProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm';
import * as Yup from 'yup';
const DeliveryOrderProductForm = ({
formState,
salesOrders,
initialValues,
exisitingValues,
onSubmitForm,
onUpdateForm,
}: {
formState: 'add' | 'edit';
salesOrders: BaseSalesOrder[];
initialValues?: DeliveryOrderProductFormValues;
exisitingValues?: DeliveryOrderProductFormValues[];
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
onUpdateForm?: (
id: number,
value: DeliveryOrderProductFormValues
) => Promise<void>;
}) => {
const [formikErrorMessage, setFormErrorMessage] = useState('');
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
null
);
const [currentInput, setCurrentInput] = useState<string>('');
const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id
);
const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
delivery_date: initialValues?.delivery_date || undefined,
vehicle_number: initialValues?.vehicle_number || undefined,
marketing_product_id:
salesOrder?.id || initialValues?.marketing_product_id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
marketing_product: initialValues?.marketing_product || undefined,
},
isInitialValid: false,
validationSchema: Yup.object().shape({
...DeliveryOrderProductSchema.fields,
qty: Yup.lazy((_, context) => {
// values diambil aman dari context
const { parent } = context;
const mpId = parent?.marketing_product_id;
const selectedSO = salesOrders.find((item) => item.id === mpId);
const maxQty = selectedSO?.qty ?? Infinity;
return Yup.number()
.min(1, 'Kuantitas wajib diisi!')
.max(maxQty, `Maksimal kuantitas adalah ${maxQty}`)
.required('Kuantitas wajib diisi!');
}),
}),
validateOnChange: true,
validateOnBlur: true,
onSubmit: async (values) => {
setFormErrorMessage('');
if (initialValues?.id) {
await onUpdateForm?.(initialValues.id, values);
} else {
await onUpdateForm?.(values.marketing_product_id as number, values);
}
handleResetForm();
},
});
const handleResetForm = () => {
setFormErrorMessage('');
formik.resetForm({
values: {
delivery_date: '',
vehicle_number: '',
marketing_product_id: undefined,
unit_price: '',
total_weight: '',
qty: '',
avg_weight: '',
total_price: '',
marketing_product: undefined,
},
});
setSelectedProduct(null);
};
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue('total_price', Number(qty) * Number(unit_price));
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue('unit_price', Number(total_price) / Number(qty));
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight));
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
}
}
};
const options = exisitingValues
?.map((item) => {
if (!Boolean(item.qty)) {
return {
value: item.id,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`,
} as OptionType;
} else {
return null;
}
})
?.filter((item) => item != null) as OptionType[];
const { setValues: setFormikValues } = formik;
useEffect(() => {
if (initialValues) {
if (!Boolean(initialValues.qty)) {
handleResetForm();
} else {
setFormikValues(initialValues);
// const value = exisitingValues?.find(
// (item) => item.id === initialValues?.id
// );
if (initialValues?.marketing_product_id) {
setSelectedProduct({
value: initialValues?.id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`,
} as OptionType);
}
}
}
}, [initialValues]);
return (
<>
<form
className='size-full'
onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm}
>
{/* <small className='block text-blue-500'>
{JSON.stringify(exisitingValues)}
</small>
<small className='block text-emerald-500'>
{JSON.stringify(formik.values)}
</small> */}
{/* <small className='block text-red-500'>
{JSON.stringify(formik.errors)}
</small>
<div className='hidden'>
{JSON.stringify(formik.values.marketing_product)}
</div> */}
{formikErrorMessage && (
<div onClick={() => setFormErrorMessage('')} className='my-3 w-full'>
<Alert color='error'>{formikErrorMessage}</Alert>
</div>
)}
<div className='grid grid-cols-2 gap-4'>
<SelectInput
options={options}
label='Produk'
placeholder='Pilih Produk'
isDisabled={formState == 'edit'}
value={
selectedProduct
? ({
value: selectedProduct?.value,
label: exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.product_warehouse?.label,
} as OptionType)
: null
}
onChange={(value) => {
const selected = value as OptionType;
setSelectedProduct(selected);
const so = salesOrders?.find(
(item) => item.id === selected?.value
);
if (!so) {
formik.setValues({
...formik.values,
marketing_product_id: undefined,
marketing_product: null,
qty: formik.values.qty || '',
unit_price: '',
total_price: '',
avg_weight: '',
total_weight: '',
vehicle_number: '',
});
return;
}
formik.setValues({
...formik.values,
marketing_product_id: selected.value as number,
marketing_product: SalesProductToFieldValues(so),
qty: formik.values.qty || so.qty,
unit_price: so.unit_price,
total_price: so.total_price,
avg_weight: so.avg_weight,
total_weight: so.total_weight,
vehicle_number: so.vehicle_number,
});
}}
startAdornment={
selectedProduct && (
<Badge
variant='soft'
color='success'
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
{
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.kandang?.label
}
</Badge>
)
}
isClearable
isError={Boolean(formik.errors.marketing_product_id)}
errorMessage={formik.errors.marketing_product_id}
required
/>
<DateInput
name='delivery_date'
label='Tanggal'
value={formik.values.delivery_date ?? undefined}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.delivery_date &&
Boolean(formik.errors.delivery_date)
}
errorMessage={formik.errors.delivery_date}
placeholder='Pilih Tanggal'
className={{
inputWrapper: 'bg-white',
}}
required
/>
<PatternInput
name='vehicle_number'
label='No. Polisi'
format='AA #### AAA'
mask='_'
inputVehicleNumber
required
type='text'
placeholder='B 1234 CDE'
value={formatVechicleNumber(formik.values.vehicle_number ?? '')}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={Boolean(formik.errors.vehicle_number)}
errorMessage={formik.errors.vehicle_number}
/>
<NumberInput
required
label='Kuantitas'
name='qty'
value={formik.values.qty}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('qty')}
isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Total Bobot (Kg)'
name='total_weight'
value={formik.values.total_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_weight')}
isError={Boolean(formik.errors.total_weight)}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
/>
<NumberInput
required
label='Total Penjualan (Rp)'
name='total_price'
value={formik.values.total_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_price')}
isError={Boolean(formik.errors.total_price)}
errorMessage={formik.errors.total_price}
placeholder='Masukan Total Penjualan'
/>
</div>
<div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning'>
Reset
</Button>
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
</form>
</>
);
};
export default DeliveryOrderProductForm;
@@ -0,0 +1,60 @@
import * as Yup from 'yup';
type SalesOrderProductSchemaType = {
id?: number | undefined;
kandang_id?: number;
kandang?: {
value: number;
label: string;
} | null;
product_warehouse?: {
value: number;
label: string;
} | null;
product_warehouse_id?: number;
unit_price: string | number | undefined;
total_weight: string | number | undefined;
qty: string | number | undefined;
avg_weight: string | number | undefined;
total_price: string | number | undefined;
vehicle_number?: string | undefined;
};
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
Yup.object({
id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
product_warehouse: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_warehouse_id: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
unit_price: Yup.number()
.min(1, 'Harga Satuan wajib diisi!')
.required('Harga Satuan wajib diisi!'),
total_weight: Yup.number()
.min(0, 'Total Bobot wajib diisi!')
.required('Total Bobot wajib diisi!'),
qty: Yup.number()
.min(1, 'Kuantitas wajib diisi!')
.required('Kuantitas wajib diisi!'),
avg_weight: Yup.number()
.min(0, 'Avg. Bobot wajib diisi!')
.required('Avg. Bobot wajib diisi!'),
total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'),
});
export type SalesOrderProductFormValues = Yup.InferType<
typeof SalesOrderProductSchema
>;
@@ -0,0 +1,337 @@
'use client';
import { useFormik } from 'formik';
import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useMemo, useState } from 'react';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatVechicleNumber } from '@/lib/helper';
import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert';
const SalesOrderProductForm = ({
initialValues,
exisitingValues,
onSubmitForm,
}: {
initialValues?: SalesOrderProductFormValues;
exisitingValues?: SalesOrderProductFormValues[];
modalRef?: RefObject<HTMLDialogElement | null>;
onSubmitForm?: (value: SalesOrderProductFormValues) => Promise<void>;
}) => {
const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>('');
const formik = useFormik<SalesOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
vehicle_number: initialValues?.vehicle_number || undefined,
kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || undefined,
product_warehouse: initialValues?.product_warehouse || undefined,
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
},
validationSchema: SalesOrderProductSchema,
onSubmit: async (values) => {
setFormErrorMessage('');
onSubmitForm?.(values);
handleResetForm();
},
validateOnBlur: true,
isInitialValid: false,
});
const {
options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const {
options: warehouseSourceOptions,
rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions,
} = useSelect<ProductWarehouse>(
ProductWarehouseApi.basePath,
'id',
'product.name',
'search',
{
warehouse_id: formik.values.kandang_id?.toString() ?? '',
}
);
const productOptionsFiltered = useMemo(() => {
return warehouseSourceOptions.filter(
(product) =>
!exisitingValues
?.map((item) => item.product_warehouse_id)
.includes(product.value)
);
}, [warehouseSourceOptions, exisitingValues]);
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('qty', null);
};
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_warehouse', val as OptionType);
const newId = (val as OptionType)?.value;
formik.setFieldValue('product_warehouse_id', newId);
if (isResponseSuccess(warehouseSourceRawData) && newId) {
const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === newId
);
formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty');
} else {
formik.setFieldValue('qty', null);
}
};
const handleResetForm = () => {
setFormErrorMessage('');
formik.resetForm({
values: {
vehicle_number: '',
kandang_id: undefined,
kandang: null,
product_warehouse: null,
product_warehouse_id: undefined,
unit_price: '',
total_weight: '',
qty: '',
avg_weight: '',
total_price: '',
},
});
};
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue(
'total_price',
(qty as number) * (unit_price as number)
);
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue(
'unit_price',
(total_price as number) / (qty as number)
);
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue(
'total_weight',
(qty as number) * (avg_weight as number)
);
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue(
'avg_weight',
(total_weight as number) / (qty as number)
);
}
}
};
return (
<>
<form
className='size-full'
onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm}
>
{formErrorMessage && (
<div onClick={() => setFormErrorMessage('')} className='my-3 w-full'>
<Alert color='error'>
{formErrorMessage ? formErrorMessage : ''}
</Alert>
</div>
)}
{/* <small className='block text-rose-500'>
{JSON.stringify(formik.errors)}
</small> */}
<div className='grid grid-cols-2 gap-4 z-200'>
<PatternInput
name='vehicle_number'
label='No. Polisi'
format='AA #### AAA'
mask='_'
inputVehicleNumber
required
type='text'
placeholder='B 1234 CDE'
value={formatVechicleNumber(formik.values.vehicle_number ?? '')}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.vehicle_number &&
Boolean(formik.errors.vehicle_number)
}
errorMessage={formik.errors.vehicle_number}
/>
<SelectInput
required
label='Kandang'
options={kandangSourceOptions}
isLoading={isLoadingKandangSourceOptions}
value={formik.values.kandang}
onChange={kandangChangeHandler}
isClearable
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
errorMessage={formik.errors.kandang_id}
placeholder='Pilih Kandang'
/>
<SelectInput
required
label='Produk'
options={productOptionsFiltered}
isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse}
onChange={warehouseChangeHandler}
isClearable
placeholder={
formik.values.kandang_id
? productOptionsFiltered.length == 0
? 'Tidak ada produk yang tersedia'
: 'Pilih produk'
: 'Pilih Kandang Terlebih Dahulu'
}
isDisabled={!formik.values.kandang_id}
isError={
formik.touched.product_warehouse_id &&
Boolean(formik.errors.product_warehouse_id)
}
errorMessage={formik.errors.product_warehouse_id}
/>
<NumberInput
required
label='Kuantitas'
name='qty'
value={formik.values.qty}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('qty')}
isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Total Bobot (Kg)'
name='total_weight'
value={formik.values.total_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_weight')}
isError={
formik.touched.total_weight && Boolean(formik.errors.total_weight)
}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
/>
<NumberInput
required
label='Total Penjualan (Rp)'
name='total_price'
value={formik.values.total_price}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_price')}
isError={
formik.touched.total_price && Boolean(formik.errors.total_price)
}
errorMessage={formik.errors.total_price}
placeholder='Masukan Total Penjualan'
/>
</div>
<div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning' onClick={handleResetForm}>
Reset
</Button>
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
</form>
</>
);
};
export default SalesOrderProductForm;
@@ -0,0 +1,272 @@
import Table from '@/components/Table';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import * as TanStack from '@tanstack/react-table';
import { useMemo, useRef } from 'react';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[];
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
onEdit: (id: number) => void;
onAddProductClick: () => void;
};
const DeliveryOrderProductTable = ({
data,
formType,
onEdit,
onAddProductClick,
}: DeliveryOrderProductTableProps) => {
const onEditRef = useRef(onEdit);
onEditRef.current = onEdit;
const canAddData = data.filter((item) => !Boolean(item.qty));
const columns = useMemo(() => {
const cols = [
// {
// id: 'select',
// header: ({
// table,
// }: {
// table: TanStack.Table<DeliveryOrderProductFormValues>;
// }) => (
// <div className='w-full flex flex-row justify-center'>
// <CheckboxInput
// name='allRow'
// checked={table.getIsAllRowsSelected()}
// indeterminate={table.getIsSomeRowsSelected()}
// onChange={table.getToggleAllRowsSelectedHandler()}
// />
// </div>
// ),
// cell: ({
// row,
// }: {
// row: TanStack.Row<DeliveryOrderProductFormValues>;
// }) => (
// <div>
// <CheckboxInput
// name='row'
// checked={row.getIsSelected()}
// disabled={!row.getCanSelect()}
// indeterminate={row.getIsSomeSelected()}
// onChange={row.getToggleSelectedHandler()}
// />
// </div>
// ),
// },
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.do_number,
header: 'No. Pengiriman',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => (
<>
{props.row.original.do_number ? props.row.original.do_number : '-'}
</>
),
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.vehicle_number,
header: 'No. Polisi',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.vehicle_number
? formatVechicleNumber(props.row.original.vehicle_number as string)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.marketing_product?.kandang?.label,
header: 'Kandang',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.marketing_product?.product_warehouse?.label,
header: 'Produk',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.delivery_date
? formatDate(row.delivery_date as string, 'DD MMM YYYY')
: '-',
header: 'Tanggal Delivery',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.delivery_date
? formatDate(
props.row.original.delivery_date as string,
'DD MMM YYYY'
)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.unit_price,
header: 'Harga Satuan (Rp)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.unit_price
? formatCurrency(
parseFloat(props.row.original.unit_price as string)
)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.total_weight,
header: 'Total Bobot (Kg)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.total_weight
? formatNumber(
parseFloat(props.row.original.total_weight as string)
)
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.qty,
header: 'Kuantitas',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.qty
? formatNumber(parseFloat(props.row.original.qty as string))
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.avg_weight,
header: 'Avg. Bobot (Kg)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.avg_weight
? formatNumber(parseFloat(props.row.original.avg_weight as string))
: '-',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.total_price,
header: 'Total Penjualan (Rp)',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) =>
props.row.original.total_price
? formatCurrency(
parseFloat(props.row.original.total_price as string)
)
: '-',
},
{
header: 'Aksi',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<>
{props.row.original.qty && (
<Button
color='warning'
className='px-2 py-1 text-sm'
onClick={() =>
onEditRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:edit' width={16} height={16} /> Edit
</Button>
)}
{!props.row.original.qty && '-'}
{/* {formType == 'add_deliver' && (
<Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
)} */}
</>
</div>
),
},
];
if (formType == 'add_deliver') {
return cols.filter((col) => col.header != 'No. Pengiriman');
}
return cols;
}, [formType, onEditRef]);
return (
<>
<Table<DeliveryOrderProductFormValues>
data={data}
columns={columns}
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-2 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
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={cn(
'w-full h-16 flex flex-col justify-center items-center gap-2'
)}
>
<span className='text-gray-500'>Belum ada data pengiriman</span>
</div>
}
/>
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={onAddProductClick}
disabled={!canAddData}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Pengiriman
</Button>
{/* {selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Pengiriman
</Button>
)} */}
</div>
</>
);
};
export default DeliveryOrderProductTable;
@@ -0,0 +1,203 @@
'use client';
import Button from '@/components/Button';
import Table from '@/components/Table';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import {
cn,
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { Icon } from '@iconify/react';
import { useMemo, useRef, useState } from 'react';
import * as TanStack from '@tanstack/react-table';
import CheckboxInput from '@/components/input/CheckboxInput';
type SalesOrderProductTableProps = {
data: SalesOrderProductFormValues[];
formType: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
rowSelection: Record<string, boolean>;
setRowSelection: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
selectedRowIds: number[];
onDelete: (id: number) => void;
onBulkDelete: () => void;
onAddProductClick: () => void;
};
const SalesOrderProductTable = ({
data,
formType,
rowSelection,
setRowSelection,
selectedRowIds,
onDelete,
onBulkDelete,
onAddProductClick,
}: SalesOrderProductTableProps) => {
const onDeleteRef = useRef(onDelete);
onDeleteRef.current = onDelete;
const columns = useMemo(
() => [
{
id: 'select',
header: ({
table,
}: {
table: TanStack.Table<SalesOrderProductFormValues>;
}) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }: { row: TanStack.Row<SalesOrderProductFormValues> }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
value={`${row.original.product_warehouse_id}${row.original.kandang_id}`}
/>
</div>
),
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatVechicleNumber(row.vehicle_number as string),
header: 'No. Polisi',
},
{
accessorFn: (row: SalesOrderProductFormValues) => row.kandang?.label,
header: 'Kandang',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
row.product_warehouse?.label,
header: 'Produk',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatCurrency(parseFloat(row.unit_price as string)),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.total_weight as string)),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.qty as string)),
header: 'Kuantitas',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.avg_weight as string)),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatCurrency(parseFloat(row.total_price as string)),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (
props: TanStack.CellContext<SalesOrderProductFormValues, unknown>
) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</div>
),
},
],
[]
);
return (
<>
<Table<SalesOrderProductFormValues>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={data}
columns={
formType == 'add_deliver' || formType == 'edit_deliver'
? columns.filter(
(col) => col.header != 'Aksi' && col.id != 'select'
)
: columns
}
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-2 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
paginationClassName: 'hidden',
}}
emptyContent={
<div
className={cn(
'w-full h-16 flex flex-col justify-center items-center gap-2'
)}
>
<span className='text-gray-500'>Belum ada data penjualan</span>
</div>
}
/>
{formType != 'add_deliver' && formType != 'edit_deliver' && (
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={onAddProductClick}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Produk
</Button>
{selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Produk
</Button>
)}
</div>
)}
</>
);
};
export default SalesOrderProductTable;
@@ -0,0 +1,235 @@
import Button from '@/components/Button';
import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path';
import { date } from 'yup';
interface DeliveryOrderExportProps {
data?: Marketing;
deliveryOrder: BaseDeliveryOrder;
className?: string;
}
const DeliveryOrderExport = ({
data,
deliveryOrder,
}: DeliveryOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data;
const handleDownloadPDF = async () => {
if (!salesData) {
alert('No sales order data available');
return;
}
setIsGeneratingPDF(true);
try {
const blob = await pdf(
<PDFDocument data={salesData} deliveryOrder={deliveryOrder} />
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${deliveryOrder?.do_number || 'delivery-order'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
};
if (!salesData) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'>No sales order data available</div>
</div>
);
}
return salesData?.so_number && salesData.so_number !== 'Belum dibuat' ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
onClick={handleDownloadPDF}
isLoading={isGeneratingPDF}
>
<Icon icon='material-symbols:file-open-outline' width={16} height={16} />
{deliveryOrder.do_number}
</Button>
) : null;
};
export default DeliveryOrderExport;
const PDFDocument = ({
data,
deliveryOrder,
}: {
data: Marketing;
deliveryOrder: BaseDeliveryOrder;
}) => {
const grandTotal = useMemo(() => {
return (
deliveryOrder.deliveries?.reduce((a, b) => a + b.total_price, 0) ?? 0
);
}, []);
return (
<Document>
<Page size='A4' style={pdfStyles.page}>
{/* Header Section */}
<View style={pdfStyles.header}>
<Image
src={'https://placehold.co/120x30/png'}
style={pdfStyles.logo}
id={'mbu-logo'}
/>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
{/* Delivery Order Title */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.title}>DELIVERY ORDER</Text>
<View style={pdfStyles.poInfo}>
<Text>{deliveryOrder.do_number || '-'}</Text>
</View>
</View>
{/* Depature Table */}
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Ship To</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Depature From</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text style={{ fontWeight: 'bold' }}>
{data?.customer?.name || '-'} ({data?.customer?.type || '-'})
</Text>
<Text style={{ marginTop: '2px' }}>
{data?.customer.email || ''} - {data?.customer.phone || ''}
</Text>
<Text></Text>
<Text>{data?.customer.address || ''}</Text>
<Text style={{ fontSize: '7px', marginTop: '3px' }}></Text>
</View>
<View style={pdfStyles.tableCellLast}>
<Text style={{ fontWeight: 'bold' }}>
{deliveryOrder.warehouse?.name || '-'}
</Text>
<Text style={{ marginTop: '2px' }}>
{formatDate(deliveryOrder.delivery_date, 'DD MMM YYYY')}
</Text>
<Text>{deliveryOrder.warehouse?.area?.name}</Text>
</View>
</View>
</View>
{/* Delivery Table */}
<Text style={pdfStyles.sectionTitle}>Product Shipped</Text>
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Item Description</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Vehicle Number</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Unit Price</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Quantity</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Total Amount</Text>
</View>
</View>
{deliveryOrder.deliveries?.map((item, index) => {
return (
<View key={index} style={[pdfStyles.tableRow]}>
<View style={pdfStyles.tableCell}>
<Text>{item.product_warehouse?.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>
{formatVechicleNumber(item.vehicle_number) || '-'}
</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>Rp{formatNumber(item.unit_price || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>{formatNumber(item.qty || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRightLast}>
<Text>Rp{formatNumber(item.total_price || 0)}</Text>
</View>
</View>
);
}) || []}
{/* Grand Total Row inside table */}
<View style={pdfStyles.grandTotalRow}>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text style={{ fontWeight: 'bold' }}>Grand Total</Text>
</View>
<View
style={[pdfStyles.tableCellRightLast, { fontWeight: 'bold' }]}
>
<Text>Rp{formatNumber(grandTotal)}</Text>
</View>
</View>
</View>
{/* Footer with Special Instructions */}
<View style={pdfStyles.footer}>
<View style={pdfStyles.specialInstructionTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>From Sales Order</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCellLast}>
<Text>
{data?.so_number || '-'} -{' '}
{formatDate(data.so_date, 'DD MMM YYYY')}
</Text>
</View>
</View>
</View>
<View style={pdfStyles.footerCompany}>
<Text>PT LUMBUNG TELUR INDONESIA</Text>
</View>
</View>
</Page>
</Document>
);
};
@@ -0,0 +1,227 @@
import Button from '@/components/Button';
import { Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber } from '@/lib/helper';
interface SalesOrderExportProps {
data?: Marketing;
className?: string;
}
const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data;
const handleDownloadPDF = async () => {
if (!salesData) {
alert('No sales order data available');
return;
}
setIsGeneratingPDF(true);
try {
const blob = await pdf(<PDFDocument data={salesData} />).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${salesData?.so_number || 'sales-order'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
};
if (!salesData) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'>No sales order data available</div>
</div>
);
}
return salesData?.so_number && salesData.so_number !== 'Belum dibuat' ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
onClick={handleDownloadPDF}
isLoading={isGeneratingPDF}
>
<Icon icon='material-symbols:file-open-outline' width={16} height={16} />
{salesData.so_number}
</Button>
) : null;
};
export default SalesOrderExport;
const PDFDocument = ({ data }: { data: Marketing }) => {
const grandTotal = useMemo(() => {
return data?.sales_order?.reduce((a, b) => a + b.total_price, 0) ?? 0;
}, [data?.sales_order]);
return (
<Document>
<Page size='A4' style={pdfStyles.page}>
{/* Header Section */}
<View style={pdfStyles.header}>
<Image
src={'https://placehold.co/120x30/png'}
style={pdfStyles.logo}
id={'mbu-logo'}
/>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
{/* Sales Order Title */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.title}>SALES ORDER</Text>
<View style={pdfStyles.poInfo}>
<Text>SO Number: {data?.so_number || '-'}</Text>
<Text>
Date:{' '}
{data?.so_date
? formatDate(data.so_date, 'DD MMM YYYY')
: formatDate(new Date(), 'DD MMM YYYY')}
</Text>
</View>
</View>
{/* Customer Table */}
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Customer</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Sales</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text style={{ fontWeight: 'bold' }}>
{data?.customer?.name || '-'} ({data?.customer?.type || '-'})
</Text>
<Text style={{ marginTop: '2px' }}>
{data?.customer.email || ''} - {data?.customer.phone || ''}
</Text>
<Text></Text>
<Text>{data?.customer.address || ''}</Text>
<Text style={{ fontSize: '7px', marginTop: '3px' }}></Text>
</View>
<View style={pdfStyles.tableCellLast}>
<Text style={{ fontWeight: 'bold' }}>
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={{ fontWeight: 'bold', marginTop: '2px' }}>
{data?.sales_person?.name || '-'}
</Text>
<Text>{data?.sales_person.email}</Text>
</View>
</View>
</View>
{/* Product Sales Order Table */}
<Text style={pdfStyles.sectionTitle}>Product Sold</Text>
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Item Description</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>From</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Unit Price</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Quantity</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Total Amount</Text>
</View>
</View>
{data?.sales_order?.map((item, index) => {
const isLastItem = index === (data?.sales_order?.length || 0) - 1;
return (
<View
key={index}
style={[
pdfStyles.tableRow,
// isLastItem ? {} : pdfStyles.tableBorderBottom,
]}
>
<View style={pdfStyles.tableCell}>
<Text>{item.product_warehouse?.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.product_warehouse?.warehouse?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>Rp{formatNumber(item.unit_price || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>{formatNumber(item.qty || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRightLast}>
<Text>Rp{formatNumber(item.total_price || 0)}</Text>
</View>
</View>
);
}) || []}
{/* Grand Total Row inside table */}
<View style={pdfStyles.grandTotalRow}>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text style={{ fontWeight: 'bold' }}>Grand Total</Text>
</View>
<View
style={[pdfStyles.tableCellRightLast, { fontWeight: 'bold' }]}
>
<Text>Rp{formatNumber(grandTotal)}</Text>
</View>
</View>
</View>
{/* Footer with Special Instructions */}
<View style={pdfStyles.footer}>
<View style={pdfStyles.specialInstructionTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Notes</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCellLast}>
<Text>{data?.notes || '-'}</Text>
</View>
</View>
</View>
<View style={pdfStyles.footerCompany}>
<Text>PT LUMBUNG TELUR INDONESIA</Text>
</View>
</View>
</Page>
</Document>
);
};
@@ -0,0 +1,212 @@
import { StyleSheet } from '@react-pdf/renderer';
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
header: {
marginBottom: 20,
},
logo: {
width: 120,
height: 30,
marginBottom: 8,
},
companyInfo: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
color: '#1f74bf',
},
address: {
fontSize: 8,
color: '#666666',
maxWidth: 400,
marginBottom: 10,
},
divider: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
marginBottom: 15,
},
titleSection: {
flexDirection: 'row',
marginBottom: 20,
justifyContent: 'space-between',
alignItems: 'flex-start',
},
title: {
fontSize: 18,
fontWeight: 'bold',
flex: 3,
color: '#1f74bf',
},
poInfo: {
flex: 1,
fontSize: 9,
textAlign: 'right',
},
sectionTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
},
tableCellLast: {
flex: 1,
padding: 8,
fontSize: 9,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellHeaderLast: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableCellRightLast: {
flex: 1,
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
grandTotalRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: '#000000',
borderTopStyle: 'solid',
},
grandTotalLabel: {
flex: 3,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
},
grandTotalValue: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 0,
},
allocationSection: {
marginBottom: 15,
},
allocationTable: {
borderWidth: 1,
borderColor: '#000000',
},
innerTable: {
marginTop: 5,
borderWidth: 1,
borderColor: '#000000',
},
innerRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
innerCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
},
innerCellLast: {
flex: 1,
padding: 8,
fontSize: 9,
},
innerCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
textAlign: 'right',
},
innerCellRightLast: {
flex: 1,
padding: 8,
fontSize: 9,
textAlign: 'right',
},
footer: {
marginTop: 30,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
footerCompany: {
fontSize: 12,
fontWeight: 'bold',
textAlign: 'right',
flex: 1,
color: '#1f74bf',
},
specialInstructionTable: {
width: '60%',
maxWidth: 300,
borderWidth: 1,
borderColor: '#000000',
flex: 1,
},
});
export default pdfStyles;
@@ -23,7 +23,7 @@ import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data'; import { KandangApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -85,12 +85,19 @@ const KandangsTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '', nameSort: '', locationSort: '', picSort: '' }, initial: {
search: '',
nameSort: '',
locationSort: '',
capacitySort: '',
picSort: '',
},
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name', nameSort: 'sort_name',
locationSort: 'sort_location', locationSort: 'sort_location',
capacitySort: 'sort_capacity',
picSort: ' sort_pic', picSort: ' sort_pic',
}, },
}); });
@@ -130,6 +137,11 @@ const KandangsTable = () => {
header: 'Lokasi', header: 'Lokasi',
cell: (props) => props.row.original.location.name, cell: (props) => props.row.original.location.name,
}, },
{
accessorKey: 'capacity',
header: 'Kapasitas',
cell: (props) => formatNumber(props.row.original.capacity ?? 0),
},
{ {
accessorKey: 'pic', accessorKey: 'pic',
header: 'PIC', header: 'PIC',
@@ -1,6 +1,28 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const KandangFormSchema = Yup.object({ type KandangFormSchemaType = {
name: string;
locationId: number | undefined;
location:
| {
value: number;
label: string;
}
| undefined
| null;
capacity: number | undefined;
picId: number | undefined;
pic:
| {
value: number;
label: string;
}
| undefined
| null;
};
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
locationId: Yup.number() locationId: Yup.number()
@@ -11,6 +33,10 @@ export const KandangFormSchema = Yup.object({
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
capacity: Yup.number()
.min(1, 'Kapasitas wajib diisi!')
.required('Kapasitas wajib diisi!'),
picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'), picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'),
pic: Yup.object({ pic: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
@@ -27,6 +27,7 @@ import {
import { LocationApi, KandangApi } from '@/services/api/master-data'; import { LocationApi, KandangApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { UserApi } from '@/services/api/user'; import { UserApi } from '@/services/api/user';
import NumberInput from '@/components/input/NumberInput';
interface KandangFormProps { interface KandangFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -81,6 +82,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
capacity: initialValues?.capacity,
picId: initialValues?.pic?.id ?? 0, picId: initialValues?.pic?.id ?? 0,
pic: initialValues?.pic pic: initialValues?.pic
? { ? {
@@ -100,8 +102,9 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
const kandangPayload: CreateKandangPayload = { const kandangPayload: CreateKandangPayload = {
name: values.name, name: values.name,
location_id: values.locationId, location_id: values.locationId!,
pic_id: values.picId, capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
pic_id: values.picId!,
}; };
switch (type) { switch (type) {
@@ -249,6 +252,21 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
isClearable isClearable
/> />
<NumberInput
required
name='capacity'
label='Kapasitas'
placeholder='Masukan kapasitas kandang'
value={formik.values.capacity}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.capacity && Boolean(formik.errors.capacity)
}
errorMessage={formik.errors.capacity as string}
readOnly={type === 'detail'}
/>
<SelectInput <SelectInput
required required
label='PIC' label='PIC'
@@ -1,6 +1,12 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProductCategoryFormSchema = Yup.object({ type ProductCategoryFormSchemaType = {
code: string;
name: string;
};
export const ProductCategoryFormSchema: Yup.ObjectSchema<ProductCategoryFormSchemaType> =
Yup.object({
code: Yup.string() code: Yup.string()
.required('Kode wajib diisi!') .required('Kode wajib diisi!')
.max(3, 'Kode kategori produk melebihi 3 karakter!'), .max(3, 'Kode kategori produk melebihi 3 karakter!'),
@@ -71,12 +71,13 @@ const ProductCategoryForm = ({
[router] [router]
); );
const formikInitialValues = useMemo<ProductCategoryFormValues>(() => { const formikInitialValues = useMemo<ProductCategoryFormValues>(
return { () => ({
code: initialValues?.code ?? '', code: initialValues?.code ?? '',
name: initialValues?.name ?? '', name: initialValues?.name ?? '',
}; }),
}, [initialValues]); [initialValues]
);
const formik = useFormik<ProductCategoryFormValues>({ const formik = useFormik<ProductCategoryFormValues>({
initialValues: formikInitialValues, initialValues: formikInitialValues,
@@ -118,7 +119,7 @@ const ProductCategoryForm = ({
await ProductCategoryApi.delete(initialValues?.id as number); await ProductCategoryApi.delete(initialValues?.id as number);
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Product Category!'); toast.success('Berhasil menghapus data Kategori Produk!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
router.push('/master-data/product-category'); router.push('/master-data/product-category');
}; };
@@ -129,7 +130,7 @@ const ProductCategoryForm = ({
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-2xl'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/master-data/product-category' href='/master-data/product-category'
@@ -141,9 +142,9 @@ const ProductCategoryForm = ({
</Button> </Button>
<h1 className='text-2xl font-bold text-center'> <h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Product Category'} {type === 'add' && 'Tambah Kategori Produk'}
{type === 'edit' && 'Edit Product Category'} {type === 'edit' && 'Edit Kategori Produk'}
{type === 'detail' && 'Detail Product Category'} {type === 'detail' && 'Detail Kategori Produk'}
</h1> </h1>
</header> </header>
@@ -157,7 +158,7 @@ const ProductCategoryForm = ({
required required
label='Kode' label='Kode'
name='code' name='code'
placeholder='Masukkan kode kategori produk' placeholder='Masukkan kode...'
value={formik.values.code} value={formik.values.code}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -169,7 +170,7 @@ const ProductCategoryForm = ({
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama kategori produk' placeholder='Masukkan nama...'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -256,7 +257,7 @@ const ProductCategoryForm = ({
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Product Category ini (${initialValues?.name})?`} text={`Apakah anda yakin ingin menghapus data Kategori Produk ini (${initialValues?.name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -1,50 +1,83 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProductFormSchema = Yup.object({ type ProductFormSchemaType = {
name: string;
brand: string;
sku: string;
uom?: {
value: number;
label: string;
} | null;
uom_id: number;
product_category?: {
value: number;
label: string;
} | null;
product_category_id: number;
product_price: number | string;
selling_price: number | string;
tax: number | string;
expiry_period: number | string;
supplier_ids: number[];
flags: string[];
};
export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'), brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string().required('SKU wajib diisi!'),
uom: Yup.object({ uom: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), })
.nullable()
.required('Satuan wajib diisi!'),
uom_id: Yup.number() uom_id: Yup.number()
.required('Satuan wajib diisi!') .required('Satuan wajib diisi!')
.typeError('Satuan wajib diisi!'), .typeError('Satuan wajib diisi!'),
product_category: Yup.object({ product_category: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), })
.nullable()
.required('Kategori produk wajib diisi!'),
product_category_id: Yup.number() product_category_id: Yup.number()
.required('Kategori produk wajib diisi!') .required('Kategori produk wajib diisi!')
.typeError('Kategori produk wajib diisi!'), .typeError('Kategori produk wajib diisi!'),
product_price: Yup.number() product_price: Yup.number()
.required('Harga produk wajib diisi!') .required('Harga produk wajib diisi!')
.typeError('Harga produk wajib diisi!') .typeError('Harga produk wajib diisi!')
.min(0, 'Harga produk tidak boleh kurang dari 0!'), .min(0, 'Harga produk tidak boleh kurang dari 0!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!') .typeError('Harga jual wajib diisi!')
.min(0, 'Harga jual tidak boleh kurang dari 0!'), .min(0, 'Harga jual tidak boleh kurang dari 0!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .required('Pajak wajib diisi!')
.typeError('Pajak wajib diisi!') .typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!') .min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'), .max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa wajib diisi!')
.min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'), .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplier_ids: Yup.array() supplier_ids: Yup.array()
.of(Yup.number().typeError('Supplier tidak valid!')) .of(Yup.number().required().typeError('Supplier tidak valid!'))
.min(1, 'Minimal harus ada 1 supplier!') .min(1, 'Minimal harus ada 1 supplier!')
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array() flags: Yup.array()
.of(Yup.string()) .of(Yup.string().required())
.min(1, 'Minimal harus ada 1 flag!') .min(1, 'Minimal harus ada 1 flag!')
.required('Flag wajib diisi!'), .required('Flag wajib diisi!'),
}); });
@@ -9,7 +9,11 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import NumberInput from '@/components/input/NumberInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
@@ -79,20 +83,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: initialValues?.sku ?? '', sku: initialValues?.sku ?? '',
uom: initialValues?.uom uom: initialValues?.uom
? { value: initialValues.uom.id, label: initialValues.uom.name } ? { value: initialValues.uom.id, label: initialValues.uom.name }
: null, : undefined,
uom_id: initialValues?.uom?.id ?? 0, uom_id: initialValues?.uom?.id ?? 0,
product_category: initialValues?.product_category product_category: initialValues?.product_category
? { ? {
value: initialValues.product_category.id, value: initialValues.product_category.id,
label: initialValues.product_category.name, label: initialValues.product_category.name,
} }
: null, : undefined,
product_category_id: initialValues?.product_category?.id ?? 0, product_category_id: initialValues?.product_category?.id ?? 0,
product_price: initialValues?.product_price ?? 0, product_price: initialValues?.product_price ?? '',
selling_price: initialValues?.selling_price ?? 0, selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? 0, tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? 0, expiry_period: initialValues?.expiry_period ?? '',
supplier: null, // not used for payload, just for UI
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [],
flags: initialValues?.flags ?? [], flags: initialValues?.flags ?? [],
}), }),
@@ -111,16 +114,14 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku, sku: values.sku,
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: values.product_price, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: values.selling_price, selling_price: parseInt(values.selling_price.toString()) || 0,
tax: values.tax, tax: parseInt(values.tax.toString()) || 0,
expiry_period: values.expiry_period, expiry_period: parseInt(values.expiry_period.toString()) || 0,
supplier_ids: (values.supplier_ids ?? []).filter( supplier_ids: values.supplier_ids.filter(
(id): id is number => typeof id === 'number' (id): id is number => typeof id === 'number'
), ),
flags: (values.flags ?? []).filter( flags: values.flags.filter((f): f is string => typeof f === 'string'),
(f): f is string => typeof f === 'string'
),
}; };
switch (type) { switch (type) {
case 'add': case 'add':
@@ -136,15 +137,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// UOM // UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState(''); const {
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; setInputValue: setUomSelectInputValue,
const { data: uoms, isLoading: isLoadingUoms } = useSWR( options: uomOptions,
uomsUrl, isLoadingOptions: isLoadingUoms,
UomApi.getAllFetcher } = useSelect(UomApi.basePath, 'id', 'name');
);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name }))
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
formik.setFieldValue('uom', val); formik.setFieldValue('uom', val);
@@ -153,15 +150,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
}; };
// Product Category // Product Category
const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); const {
const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; setInputValue: setCategorySelectInputValue,
const { data: categories, isLoading: isLoadingCategories } = useSWR( options: categoryOptions,
categoriesUrl, isLoadingOptions: isLoadingCategories,
ProductCategoryApi.getAllFetcher } = useSelect(ProductCategoryApi.basePath, 'id', 'name');
);
const categoryOptions = isResponseSuccess(categories)
? categories?.data.map((cat) => ({ value: cat.id, label: cat.name }))
: [];
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true); formik.setFieldTouched('product_category', true);
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
@@ -169,7 +162,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
formik.setFieldValue('product_category_id', (val as OptionType)?.value); formik.setFieldValue('product_category_id', (val as OptionType)?.value);
}; };
// Supplier (multi select) // Supplier (multi select) - using SWR to filter by category
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
@@ -209,7 +202,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-2xl'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/master-data/product' href='/master-data/product'
@@ -235,7 +228,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama produk' placeholder='Masukkan nama...'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -247,7 +240,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Merek' label='Merek'
name='brand' name='brand'
placeholder='Masukkan merek produk' placeholder='Masukkan merek...'
value={formik.values.brand} value={formik.values.brand}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -259,7 +252,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='SKU' label='SKU'
name='sku' name='sku'
placeholder='Masukkan SKU produk' placeholder='Masukkan SKU...'
value={formik.values.sku} value={formik.values.sku}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -270,6 +263,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Satuan' label='Satuan'
placeholder='Pilih satuan...'
value={formik.values.uom ?? undefined} value={formik.values.uom ?? undefined}
onChange={uomChangeHandler} onChange={uomChangeHandler}
options={uomOptions} options={uomOptions}
@@ -283,6 +277,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Kategori Produk' label='Kategori Produk'
placeholder='Pilih kategori produk...'
value={formik.values.product_category ?? undefined} value={formik.values.product_category ?? undefined}
onChange={categoryChangeHandler} onChange={categoryChangeHandler}
options={categoryOptions} options={categoryOptions}
@@ -296,15 +291,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
<TextInput <NumberInput
required required
label='Harga Produk' label='Harga Produk'
name='product_price' name='product_price'
type='number' placeholder='Masukkan harga produk...'
placeholder='Masukkan harga produk'
value={formik.values.product_price} value={formik.values.product_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={ isError={
formik.touched.product_price && formik.touched.product_price &&
Boolean(formik.errors.product_price) Boolean(formik.errors.product_price)
@@ -312,15 +311,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.product_price as string} errorMessage={formik.errors.product_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Harga Jual' label='Harga Jual'
name='selling_price' name='selling_price'
type='number' placeholder='Masukkan harga jual...'
placeholder='Masukkan harga jual'
value={formik.values.selling_price} value={formik.values.selling_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={ isError={
formik.touched.selling_price && formik.touched.selling_price &&
Boolean(formik.errors.selling_price) Boolean(formik.errors.selling_price)
@@ -328,28 +331,36 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.selling_price as string} errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Pajak (%)' label='Pajak (%)'
name='tax' name='tax'
type='number' placeholder='Masukkan pajak...'
placeholder='Masukkan pajak'
value={formik.values.tax} value={formik.values.tax}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='%'
isError={formik.touched.tax && Boolean(formik.errors.tax)} isError={formik.touched.tax && Boolean(formik.errors.tax)}
errorMessage={formik.errors.tax as string} errorMessage={formik.errors.tax as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Periode Kadaluarsa (hari)' label='Periode Kadaluarsa (hari)'
name='expiry_period' name='expiry_period'
type='number' placeholder='Masukkan periode kadaluarsa...'
placeholder='Masukkan periode kadaluarsa'
value={formik.values.expiry_period} value={formik.values.expiry_period}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='hari'
isError={ isError={
formik.touched.expiry_period && formik.touched.expiry_period &&
Boolean(formik.errors.expiry_period) Boolean(formik.errors.expiry_period)
@@ -360,9 +371,10 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Supplier' label='Supplier'
placeholder='Pilih supplier...'
isMulti isMulti
value={supplierOptions.filter((opt) => value={supplierOptions.filter((opt) =>
formik.values.supplier_ids.includes(opt.value) (formik.values.supplier_ids || []).includes(opt.value)
)} )}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions} options={supplierOptions}
@@ -379,9 +391,10 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Flags' label='Flags'
placeholder='Pilih flags...'
isMulti isMulti
value={PRODUCT_FLAG_OPTIONS.filter((opt) => value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
formik.values.flags.includes(opt.value) (formik.values.flags || []).includes(opt.value)
)} )}
onChange={(val) => { onChange={(val) => {
const arr = Array.isArray(val) ? val : val ? [val] : []; const arr = Array.isArray(val) ? val : val ? [val] : [];
@@ -13,14 +13,13 @@ import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
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, formatNumber } from '@/lib/helper'; import { cn, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production'; import { ChickinApi } from '@/services/api/production/chickin';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Chickin } from '@/types/api/production/chickin'; import { Chickin } from '@/types/api/production/chickin';
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 { useState } from 'react'; import { useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import ChickinForm from './form/ChickinForm';
const ChickinTable = () => { const ChickinTable = () => {
const { const {
@@ -87,7 +86,7 @@ const ChickinTable = () => {
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'> <div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<Button <Button
href='/production/chickin/add?projectFlockId=1' href='/production/project-flock/chickin/add?projectFlockId=1'
variant='outline' variant='outline'
color='primary' color='primary'
className='w-full sm:w-fit' className='w-full sm:w-fit'
@@ -260,14 +259,14 @@ const ChickinTable = () => {
/> />
</Button> </Button>
</div> </div>
<ChickinForm {/* <ChickinForm
initialValues={selectedChickin} initialValues={selectedChickin}
formType='edit' formType='edit'
afterSubmit={() => { afterSubmit={() => {
refreshChickins(); refreshChickins();
chickinModal.closeModal(); chickinModal.closeModal();
}} }}
/> /> */}
</Modal> </Modal>
</> </>
); );
@@ -287,7 +286,7 @@ const RowOptionsMenu = ({
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<Button <Button
href={`/production/chickin/detail?chickinId=${props.row.original.id}`} href={`/production/project-flock/chickin/detail?chickinId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='primary' color='primary'
className='justify-start text-sm' className='justify-start text-sm'
@@ -1,13 +1,37 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ChickinFormSchema = Yup.object({ type ChickinRequestSchemaType = {
chick_in_date: Yup.string().required('Tanggal masuk wajib diisi!'), chick_in_date: string;
note: Yup.string().required('Catatan wajib diisi!'), note?: string | undefined | null;
quantity: Yup.number() product_warehouse_id: number;
.min(1, 'Jumlah wajib diisi!') };
.required('Jumlah wajib diisi!'),
type ChickinSchemaType = {
project_flock_kandang_id: number;
chickin_requests: ChickinRequestSchemaType[];
};
export const ChickinRequestSchema: Yup.ObjectSchema<ChickinRequestSchemaType> =
Yup.object({
chick_in_date: Yup.string().nullable().required('Tanggal wajib diisi!'),
note: Yup.string().nullable(),
product_warehouse_id: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
}); });
export type ChickinFormValues = Yup.InferType<typeof ChickinFormSchema>; export const ChickinSchema: Yup.ObjectSchema<ChickinSchemaType> = Yup.object({
project_flock_kandang_id: Yup.number()
.min(1, 'Project Flock Kandang wajib diisi!')
.required('Project Flock Kandang wajib diisi!'),
chickin_requests: Yup.array()
.of(ChickinRequestSchema)
.min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'),
});
export const UpdateChickinFormSchema = ChickinFormSchema; export type ChickinRequestFormValues = Yup.InferType<
typeof ChickinRequestSchema
>;
export type ChickinFormValues = Yup.InferType<typeof ChickinSchema>;
@@ -1,220 +1,145 @@
'use client'; 'use client';
import Button from '@/components/Button'; import Card from '@/components/Card';
import { import { FormHeader } from '@/components/helper/form/FormHeader';
Chickin, import Table from '@/components/Table';
CreateChickinPayload, import { formatNumber } from '@/lib/helper';
UpdateChickinPayload, import { Kandang } from '@/types/api/master-data/kandang';
} from '@/types/api/production/chickin'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { import Tabs from '@/components/Tabs';
ChickinFormSchema, import ChickinFormView from './tabs/ChickinFormView';
ChickinFormValues, import ChickinLogsView from './tabs/ChickLogsView';
UpdateChickinFormSchema, import { useState } from 'react';
} from '@/components/pages/production/chickin/form/ChickinForm.schema'; import ApprovalSteps, {
import { use, useCallback, useEffect, useMemo, useState } from 'react'; useApprovalSteps,
import { useFormik } from 'formik'; } from '@/components/pages/ApprovalSteps';
import { ChickinApi } from '@/services/api/production'; import { PROJECT_FLOCK_KANDANG_APPROVAL_LINE } from '@/config/approval-line';
import DateInput from '@/components/input/DateInput'; const ChickinFormKandang = ({
import { isResponseError } from '@/lib/api-helper';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import TextArea from '@/components/input/TextArea';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
interface ChickinFormProps {
formType?: 'add' | 'detail' | 'edit';
initialValues?: Chickin;
afterSubmit?: () => void;
}
const ChickinForm = ({
formType = 'add', formType = 'add',
initialValues, initialValues,
afterSubmit, afterSubmit,
}: ChickinFormProps) => { }: {
// Helper Function formType?: 'add' | 'detail' | 'edit';
const formatDateForInput = (dateString?: string): string => { initialValues: ProjectFlockKandang;
if (!dateString) return ''; afterSubmit?: () => void;
return new Date(dateString).toISOString().split('T')[0]; }) => {
}; const [activeTabId, setActiveTabId] = useState<string>('formChickIn');
// State const {
const [chickinFormErrorMessage, setChickinFormErrorMessage] = useState(''); approvals,
isLoading: approvalsLoading,
// Initial Value refresh: refreshApprovals,
const formikInitialValue = useMemo<ChickinFormValues>(() => { } = useApprovalSteps({
return { latestApproval: initialValues?.approval,
chick_in_date: formatDateForInput(initialValues?.chick_in_date) ?? '', approvalLines: PROJECT_FLOCK_KANDANG_APPROVAL_LINE,
note: initialValues?.note ?? '', moduleName: 'PROJECT_FLOCK_KANDANGS',
quantity: moduleId: initialValues?.id.toString() ?? '',
initialValues?.quantity ??
initialValues?.project_flock_kandang?.available_quantity ??
0,
};
}, [initialValues]);
// Handle Submit Function
const handleCreate = useCallback(
async (
payload: CreateChickinPayload,
afterSubmit: (() => void) | undefined
) => {
const res = await ChickinApi.create(payload);
if (isResponseError(res)) {
setChickinFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
afterSubmit?.();
},
[]
);
const handleUpdate = useCallback(
async (
payload: UpdateChickinPayload,
afterSubmit: (() => void) | undefined
) => {
const res = await ChickinApi.update(payload.id, payload);
if (isResponseError(res)) {
setChickinFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
afterSubmit?.();
},
[]
);
// Formik
const formik = useFormik<ChickinFormValues>({
initialValues: formikInitialValue,
enableReinitialize: true,
validationSchema:
formType === 'edit' ? UpdateChickinFormSchema : ChickinFormSchema,
onSubmit: async (values) => {
// reset error message
setChickinFormErrorMessage('');
if (
initialValues?.project_flock_kandang?.id == undefined ||
(formType == 'edit' && initialValues?.id == undefined)
) {
return;
}
// create payload
const payload = {
chick_in_date: values.chick_in_date,
project_flock_kandang_id: initialValues?.project_flock_kandang?.id,
note: values.note,
quantity: values.quantity,
id: initialValues.id ?? 0,
};
// cek type form yang disubmit
console.log(formType);
switch (formType) {
case 'add':
handleCreate(payload, afterSubmit);
break;
case 'edit':
handleUpdate(payload, afterSubmit);
break;
default:
break;
}
},
}); });
// Initialize Formik const afterSubmitFormChickin = () => {
const { setValues: formikSetValues } = formik; setActiveTabId('logsChickIn');
useEffect(() => { afterSubmit && afterSubmit();
formikSetValues(formikInitialValue); refreshApprovals();
}, [formikSetValues, formikInitialValue]); };
return ( return (
<> <div className='flex flex-col gap-4'>
<form <FormHeader
className='min-h-48 flex flex-col gap-4' title={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
onSubmit={formik.handleSubmit} backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`}
onReset={formik.handleReset}
>
<DateInput
value={formik.values.chick_in_date}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
name='chick_in_date'
label='Tanggal Chickin'
required
isError={
formik.touched.chick_in_date && Boolean(formik.errors.chick_in_date)
}
errorMessage={formik.errors.chick_in_date}
/> />
<NumberInput
value={formik.values.quantity} {approvals && !approvalsLoading && (
onChange={formik.handleChange} <ApprovalSteps approvals={approvals} />
onBlur={formik.handleBlur}
name='quantity'
label='Jumlah (Ekor)'
required
isError={
(formik.touched.quantity && Boolean(formik.errors.quantity)) ||
formik.values.quantity == 0
}
errorMessage={
formik.values.quantity == 0
? 'Masukan Persediaan Day Old Chick terlebih dahulu.'
: formik.errors.quantity
}
readOnly
/>
<TextArea
required
label='Catatan'
name='note'
placeholder='Masukan catatan chickin'
value={formik.values.note}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.note && Boolean(formik.errors.note)}
errorMessage={formik.errors.note}
/>
{initialValues?.project_flock_kandang?.id == undefined && (
<p className='text-error'>Project Flock Kandang tidak ditemukan.</p>
)} )}
{chickinFormErrorMessage && (
<div <Card
role='alert' title='Informasi Kandang'
className='alert alert-error' className={{
onClick={() => { wrapper: 'w-full bg-white mt-4',
setChickinFormErrorMessage('');
}} }}
> >
<Icon icon='mdi:times' /> <Table<Kandang>
<span>{chickinFormErrorMessage}</span> emptyContent={
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Informasi Kandang belum tersedia...
</span>
</div> </div>
)}
<div className='flex justify-center mt-auto gap-2'>
<Button color='warning' type='reset'>
Reset
</Button>
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={
!formik.isValid ||
formik.isSubmitting ||
!initialValues?.project_flock_kandang?.id
} }
> data={[initialValues?.kandang]}
Submit columns={[
</Button> {
header: 'Area',
accessorFn: () => initialValues?.project_flock?.area.name || '-',
},
{
header: 'Lokasi',
accessorFn: () =>
initialValues?.project_flock?.location.name || '-',
},
{
header: 'Flock',
accessorFn: () => initialValues?.project_flock?.flock_name || '-',
},
{
header: 'Kandang',
accessorFn: (row) => row?.name || '-',
},
{
header: 'Kapasitas',
accessorFn: (row) =>
(row?.capacity && formatNumber(row?.capacity)) || '-',
},
{
header: 'Penanggung Jawab',
accessorFn: (row) => row?.pic?.name || '-',
},
]}
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-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',
}}
/>
</Card>
<Tabs
className='bg-white p-2'
onTabChange={setActiveTabId}
activeTabId={activeTabId}
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> </div>
</form>
</>
); );
}; };
export default ChickinForm; export default ChickinFormKandang;
@@ -0,0 +1,176 @@
import Alert from '@/components/Alert';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin';
import {
Chickin,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { useState } from 'react';
import toast from 'react-hot-toast';
const ChickinLogsView = ({
initialValues,
afterSubmit,
}: {
initialValues: ProjectFlockKandang;
afterSubmit?: () => void;
}) => {
const confirmModal = useModal();
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const handleClickApprove = () => {
confirmModal.openModal();
};
const confirmationModalApproveClickHandler = async (notes?: string) => {
setChickinErrorMessage('');
setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.singleApproval(
initialValues?.id as number,
'APPROVED',
notes
);
if (isResponseSuccess(approveChickinRes)) {
toast.success(approveChickinRes?.message as string);
}
if (isResponseError(approveChickinRes)) {
toast.error(approveChickinRes?.message as string);
setChickinErrorMessage(approveChickinRes?.message as string);
}
confirmModal.closeModal();
setIsApproveLoading(false);
afterSubmit && afterSubmit();
};
return (
<>
<Card
title='Riwayat Chick In'
className={{
wrapper: 'w-full bg-white',
}}
>
<div className='flex flex-row justify-start gap-3 mt-3'>
{initialValues?.approval?.step_number == 1 && (
<Button
color='success'
variant='outline'
onClick={handleClickApprove}
>
<Icon width={24} height={24} icon='material-symbols:check' />
Approve
</Button>
)}
</div>
<Table<Chickin>
data={initialValues?.chickins || []}
columns={[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorFn: (row) => row.chick_in_date,
header: 'Tanggal Chick In',
cell: (props) => {
return formatDate(props.getValue() as string, 'DD MMM YYYY');
},
},
{
accessorFn: (row) => row.product_warehouse?.warehouse?.name,
header: 'Kandang',
},
{
accessorFn: (row) => row.product_warehouse?.product?.name,
header: 'Produk',
},
{
accessorFn: (row) => row.usage_qty ?? row.pending_usage_qty,
header: 'Jumlah Chick In',
cell: (props) => {
if (props.row.original.usage_qty != 0) {
return formatNumber(props.row.original.usage_qty);
} else if (props.row.original.pending_usage_qty != 0) {
return formatNumber(props.row.original.pending_usage_qty);
} else {
return '-';
}
},
},
{
accessorFn: (row) => row.pending_usage_qty,
header: 'Status',
cell: (props) => {
return (
<PillBadge
content={
props.row.original.usage_qty !== 0
? 'Disetujui'
: props.row.original.pending_usage_qty !== 0
? 'Pending'
: '-'
}
color={
props.row.original.usage_qty !== 0
? 'green'
: props.row.original.pending_usage_qty !== 0
? 'yellow'
: 'gray'
}
/>
);
},
},
]}
className={{
containerClassName: cn({
'mb-20': initialValues?.chickins?.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',
}}
/>
{chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert>
</div>
)}
</Card>
<ConfirmationModalWithNotes
ref={confirmModal.ref}
type='success'
text={`Apakah anda yakin ingin approve data Chickin yang Pending?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
onClick: (notes) => {
confirmationModalApproveClickHandler(notes);
},
isLoading: isApproveLoading,
}}
/>
</>
);
};
export default ChickinLogsView;
@@ -0,0 +1,230 @@
'use client';
import Card from '@/components/Card';
import Table from '@/components/Table';
import {
ChickinFormValues,
ChickinRequestFormValues,
ChickinSchema,
} from '@/components/pages/production/chickin/form/ChickinForm.schema';
import DateInput from '@/components/input/DateInput';
import Button from '@/components/Button';
import { useCallback, useEffect, useState } from 'react';
import { useFormik } from 'formik';
import { flushSync } from 'react-dom';
import { CreateChickinPayload } from '@/types/api/production/chickin';
import { ChickinApi } from '@/services/api/production/chickin';
import { isResponseError } from '@/lib/api-helper';
import toast from 'react-hot-toast';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { useRouter } from 'next/navigation';
import Alert from '@/components/Alert';
import { formatNumber } from '@/lib/helper';
const ChickinFormView = ({
initialValues,
afterSubmit,
}: {
formType?: 'add' | 'detail' | 'edit';
initialValues: ProjectFlockKandang;
afterSubmit?: () => void;
}) => {
const router = useRouter();
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const createChickin = useCallback(
async (payload: CreateChickinPayload) => {
const createChickinRes = await ChickinApi.create(payload);
if (isResponseError(createChickinRes)) {
setChickinErrorMessage(createChickinRes.message);
return;
}
toast.success(createChickinRes?.message as string);
// router.push(
// `/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`
// );
if (afterSubmit) {
afterSubmit();
}
},
[router]
);
const handleReset = async () => {
flushSync(() => {
formik.resetForm({
values: {
project_flock_kandang_id: initialValues?.id,
chickin_requests: initialValues?.available_qtys
? initialValues.available_qtys.map((availableQty) => ({
chick_in_date: '',
product_warehouse_id: availableQty.product_warehouse.id,
available_qty: availableQty.available_qty,
note: `Chickin project-flock-kandang-${initialValues?.id} product-warehouse-${availableQty.product_warehouse.id}`,
}))
: [],
},
});
});
formik.setTouched({
chickin_requests: initialValues?.available_qtys?.map(() => ({
chick_in_date: true,
})),
});
const errors = await formik.validateForm();
formik.setErrors(errors);
};
const formik = useFormik<ChickinFormValues>({
enableReinitialize: true,
validationSchema: ChickinSchema,
initialValues: {
project_flock_kandang_id: initialValues?.id,
chickin_requests: initialValues?.available_qtys
? initialValues.available_qtys.map((availableQty) => ({
chick_in_date: '',
product_warehouse_id: availableQty.product_warehouse.id,
available_qty: availableQty.available_qty,
note: `Chickin project-flock-kandang-${initialValues?.id} product-warehouse-${availableQty.product_warehouse.id}`,
}))
: [],
},
onSubmit: (values) => {
setChickinErrorMessage('');
createChickin(values as CreateChickinPayload);
if (afterSubmit) {
afterSubmit();
}
},
});
const { setValues: formikSetValues } = formik;
useEffect(() => {
formikSetValues({
project_flock_kandang_id: initialValues?.id,
chickin_requests: initialValues?.available_qtys
? initialValues.available_qtys.map((availableQty) => ({
chick_in_date: '',
product_warehouse_id: availableQty.product_warehouse.id,
available_qty: availableQty.available_qty,
note: `Chickin project-flock-kandang-${initialValues?.id} product-warehouse-${availableQty.product_warehouse.id}`,
}))
: [],
});
}, [formikSetValues, initialValues]);
return (
<form
className='flex flex-col gap-4'
onReset={() => {
handleReset();
}}
onSubmit={formik.handleSubmit}
>
<Card
title='Informasi Chick In DOC'
className={{
wrapper: 'w-full bg-white',
}}
>
<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>
}
/>
</Card>
<div className='flex flex-row justify-center gap-3'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
</Button>
<Button
type='submit'
color='primary'
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
{chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert>
</div>
)}
</form>
);
};
export default ChickinFormView;
@@ -6,15 +6,15 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
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 } 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'; 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 { BaseApiResponse } from '@/types/api/api-general';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
@@ -38,7 +38,17 @@ const RowOptionsMenu = ({
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
return ( return (
<RowOptionsMenuWrapper type={type}> <div
tabIndex={type == 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<div className='flex flex-col gap-1'>
<Button <Button
href={`/production/project-flock/detail?projectFlockId=${props.row.original.id}`} href={`/production/project-flock/detail?projectFlockId=${props.row.original.id}`}
variant='ghost' variant='ghost'
@@ -50,7 +60,7 @@ const RowOptionsMenu = ({
</Button> </Button>
{props.row.original.approval.step_name === 'Aktif' && ( {props.row.original.approval.step_name === 'Aktif' && (
<Button <Button
href={`/production/chickin/add?projectFlockId=${props.row.original.id}`} href={`/production/project-flock/chickin/add?projectFlockId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='success' color='success'
className='justify-start text-sm' className='justify-start text-sm'
@@ -74,17 +84,17 @@ const RowOptionsMenu = ({
onClick={deleteClickHandler} onClick={deleteClickHandler}
variant='ghost' variant='ghost'
color='error' color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content' className='text-error hover:text-inherit justify-start text-sm'
> >
<Icon <Icon
icon='material-symbols:delete-outline-rounded' icon='material-symbols:delete-outline-rounded'
width={16} width={16}
height={16} height={16}
className='justify-start text-sm'
/> />
Delete Delete
</Button> </Button>
</RowOptionsMenuWrapper> </div>
</div>
); );
}; };
@@ -135,6 +145,9 @@ const ProjectFlockTable = () => {
useState<ProjectFlock>(); useState<ProjectFlock>();
const deleteModal = useModal(); const deleteModal = useModal();
const confirmModal = useModal(); const confirmModal = useModal();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
@@ -217,18 +230,21 @@ const ProjectFlockTable = () => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
const confirmationModalApproveClickHandler = async () => { const confirmApprovalHandler = async (
notes: string,
approvalAction: 'APPROVED' | 'REJECTED'
) => {
setIsApproveLoading(true); setIsApproveLoading(true);
const approveProjectFlockRes = await ProjectFlockApi.customRequest< const approveProjectFlockRes =
BaseApiResponse<ProjectFlock>, approvalAction === 'APPROVED'
ProjectFlockApprovalPayload ? await ProjectFlockApi.bulkApprove(
>(`/approvals`, { selectedRowIds.map((id) => id),
method: 'POST', notes
payload: { )
action: 'APPROVED', : await ProjectFlockApi.bulkReject(
approvable_ids: selectedRowIds.map((id) => id), selectedRowIds.map((id) => id),
}, notes
}); );
if (isResponseSuccess(approveProjectFlockRes)) { if (isResponseSuccess(approveProjectFlockRes)) {
toast.success('Project Flock berhasil di-approve!'); toast.success('Project Flock berhasil di-approve!');
@@ -262,6 +278,7 @@ const ProjectFlockTable = () => {
variant='outline' variant='outline'
color='success' color='success'
onClick={() => { onClick={() => {
setApprovalAction('APPROVED');
confirmModal.openModal(); confirmModal.openModal();
}} }}
disabled={selectedRowIds.length === 0} disabled={selectedRowIds.length === 0}
@@ -270,6 +287,19 @@ const ProjectFlockTable = () => {
<Icon icon='material-symbols:check' width={24} height={24} /> <Icon icon='material-symbols:check' width={24} height={24} />
Approve Approve
</Button> </Button>
<Button
variant='outline'
color='error'
onClick={() => {
setApprovalAction('REJECTED');
confirmModal.openModal();
}}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</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'
@@ -417,7 +447,7 @@ const ProjectFlockTable = () => {
}, },
{ {
accessorKey: 'flock.name', accessorKey: 'flock_name',
header: 'Flock', header: 'Flock',
}, },
{ {
@@ -549,7 +579,7 @@ const ProjectFlockTable = () => {
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${selectedProjectFlock?.flock?.name})?`} text={`Apakah anda yakin ingin menghapus data Project Flock ini (${selectedProjectFlock?.flock_name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -561,29 +591,19 @@ const ProjectFlockTable = () => {
}} }}
/> />
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type='success' type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin reject data transfer ke laying ini (${selectedRowIds.length} data)?`} text={`Apakah anda yakin ingin ${approvalAction == 'APPROVED' ? 'approve' : 'reject'} data Project Flock ini (${selectedRowIds.length} data)?`}
// text={
// selectedFlocks.length > 0
// ? `Apakah anda yakin ingin approve Project Flock berikut? (${selectedFlocks
// .map(
// (flock) =>
// `${flock.flock?.name ?? '(Tanpa nama)'} - ${
// flock.area?.name ?? '-'
// }`
// )
// .join(', ')})`
// : 'Tidak ada Project Flock yang dipilih.'
// }
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'success', color: approvalAction == 'APPROVED' ? 'success' : 'error',
onClick: confirmationModalApproveClickHandler, onClick: (notes) => {
confirmApprovalHandler(notes, approvalAction);
},
isLoading: isApproveLoading, isLoading: isApproveLoading,
}} }}
/> />
@@ -0,0 +1,359 @@
'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import Card from '@/components/Card';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { FormHeader } from '@/components/helper/form/FormHeader';
const ProjectFlockChickinDetail = ({
projectFlockId,
}: {
projectFlockId: number | undefined;
}) => {
const router = useRouter();
// Tables Props
const { state: tableFilterState } = useTableFilter({
initial: { search: '' },
paramMap: { page: 'page', pageSize: 'limit' },
});
// States
const [searchProjectFlock, setSearchProjectFlock] = useState('');
const [selectedProjectFlock, setSelectedProjectFlock] =
useState<OptionType | null>(null);
const [projectFlock, setProjectFlock] = useState<ProjectFlock>();
// Fetch Data
const { data: listProjectFlockKandang } = useSWR(
`${ProjectFlockKandangApi.basePath}?${new URLSearchParams({
search: searchProjectFlock,
project_flock_id:
projectFlock?.id?.toString() ?? projectFlockId?.toString() ?? '',
}).toString()}`,
ProjectFlockKandangApi.getAllFetcher
);
const {
options: options,
isLoadingOptions: isLoadingListProjectFlock,
rawData: listProjectFlock,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'',
{
search: searchProjectFlock,
}
);
// Handle Function
const handleChickinClick = async (
projectFlockKandang: ProjectFlockKandang
) => {
router.push(
`/production/project-flock/chickin/add/kandang?projectFlockKandangId=${projectFlockKandang.id}&projectFlockId=${projectFlockId ?? selectedProjectFlock?.value}`
);
};
const handleChangeProjectFlock = (val: OptionType | null) => {
setSelectedProjectFlock(val);
if (isResponseSuccess(listProjectFlock) && val) {
const selected = listProjectFlock.data.find(
(pf) => pf.id === Number(val.value)
);
setProjectFlock(selected);
} else {
setProjectFlock(undefined);
}
if (projectFlockId) {
router.push('/production/project-flock/chickin/add');
}
if (!val && projectFlockId) {
router.push('/production/project-flock/chickin/add');
}
};
useEffect(() => {
if (projectFlockId && isResponseSuccess(listProjectFlock)) {
setProjectFlock(
listProjectFlock.data.find((pf) => pf.id === Number(projectFlockId))
);
}
}, [projectFlockId, listProjectFlock]);
return (
<>
<FormHeader
title={`Chick In ${projectFlock?.flock_name ?? 'Project Flock'}`}
backUrl='/production/project-flock'
/>
<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'>
<SelectInput
required
label='Ganti Project Flock'
placeholder='Pilih Project Flock'
options={options}
onInputChange={(val) => {
setSearchProjectFlock(val);
}}
isLoading={isLoadingListProjectFlock}
value={
projectFlock
? {
label: `${projectFlock?.flock_name}`,
value: projectFlock?.id,
}
: null
}
onChange={(val) => {
handleChangeProjectFlock(val as OptionType | null);
}}
isSearchable
isClearable
startAdornment={
projectFlock && (
<Badge
variant='soft'
color='success'
size='sm'
className={{
badge: 'whitespace-nowrap font-semibold',
}}
>
Periode {projectFlock?.period}
</Badge>
)
}
/>
</div>
</div>
<Card
title='Informasi Flock'
className={{
wrapper: 'w-full bg-white mb-3',
}}
>
<Table<ProjectFlock>
emptyContent={
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Pilih project flock terlebih dahulu...
</span>
</div>
}
data={projectFlock ? [projectFlock] : []}
columns={[
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Area',
accessorKey: 'area.name',
},
{
header: 'Lokasi',
accessorKey: 'location.name',
},
{
header: 'Nama Flock',
accessorKey: 'flock_name',
},
{
header: 'Kategori',
accessorKey: 'category',
},
{
header: 'Status',
accessorKey: 'status',
cell: (props) => {
return props.row.original.approval?.step_name ? (
<PillBadge
color={(() => {
switch (
props.row.original.approval?.step_name.toUpperCase()
) {
case 'AKTIF':
return 'red';
case 'PENGAJUAN':
return 'green';
default:
return 'gray';
}
})()}
content={props.row.original.approval?.step_name
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())}
/>
) : (
'-'
);
},
},
{
header: 'FCR Layer',
accessorKey: 'fcr.name',
},
]}
page={undefined}
className={{
containerClassName: cn({
'mb-20': projectFlock && projectFlock.kandangs?.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',
}}
/>
</Card>
<Card
title='Daftar Kandang'
className={{
wrapper: 'w-full bg-white',
}}
>
<Table<ProjectFlockKandang>
emptyContent={
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Pilih project flock terlebih dahulu...
</span>
</div>
}
data={
projectFlock && isResponseSuccess(listProjectFlockKandang)
? listProjectFlockKandang.data
: []
}
columns={[
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorFn: (row) => row?.project_flock?.area?.name,
header: 'Area',
},
{
accessorFn: (row) => row?.project_flock?.location?.name,
header: 'Lokasi',
},
{
accessorKey: 'kandang.name',
header: 'Kandang',
},
{
accessorKey: 'kandang.capacity',
header: 'Kapasitas',
},
{
accessorFn: () => projectFlock?.period,
header: 'Periode',
},
{
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
return props.row.original.approval?.step_name ? (
<PillBadge
color={(() => {
switch (
props.row.original.approval?.step_name.toUpperCase()
) {
case 'DISETUJUI':
return 'green';
case 'PENGAJUAN':
return 'yellow';
default:
return 'gray';
}
})()}
content={props.row.original.approval?.step_name
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())}
/>
) : projectFlock?.approval?.step_number === 1 ? (
<PillBadge color='red' content={'Tidak Dapat Chick In'} />
) : (
<PillBadge color='gray' content={'Belum Chick In'} />
);
},
},
{
header: 'Aksi',
cell: (props) => {
return (
<>
<Button
color='success'
variant='outline'
onClick={() => {
handleChickinClick(props.row.original);
}}
className='p-1'
disabled={projectFlock?.approval?.step_number === 1}
>
<Icon
icon='mdi:home-import-outline'
width={18}
height={18}
/>
Chickin
</Button>
</>
);
},
},
]}
page={undefined}
className={{
containerClassName: cn({
'mb-20': projectFlock && projectFlock.kandangs?.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',
}}
/>
</Card>
</>
);
};
export default ProjectFlockChickinDetail;
@@ -6,9 +6,7 @@ export const ProjectFlockFormSchema = Yup.object({
value: Yup.number().required('ID Flock wajib diisi!'), value: Yup.number().required('ID Flock wajib diisi!'),
label: Yup.string().required('Nama Flock wajib diisi!'), label: Yup.string().required('Nama Flock wajib diisi!'),
}).nullable(), }).nullable(),
flock_id: Yup.number() flock_name: Yup.string().required('Nama Flock wajib diisi!'),
.min(1, 'Flock wajib diisi!')
.required('Flock wajib diisi!'),
// Area // Area
area: Yup.object({ area: Yup.object({
@@ -44,11 +42,6 @@ export const ProjectFlockFormSchema = Yup.object({
.min(1, 'Lokasi wajib diisi!') .min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'), .required('Lokasi wajib diisi!'),
period: Yup.number()
.required('Periode wajib diisi!')
.typeError('Periode harus berupa angka')
.min(1, 'Minimal periode adalah 1'),
kandang_ids: Yup.array() kandang_ids: Yup.array()
.of(Yup.number().typeError('Kandang tidak valid!')) .of(Yup.number().typeError('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!') .min(1, 'Minimal harus ada 1 kandang!')
@@ -1,7 +1,10 @@
'use client'; 'use client';
import Button from '@/components/Button'; import Button from '@/components/Button';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { import {
AreaApi, AreaApi,
@@ -23,19 +26,22 @@ import {
import { import {
ProjectFlockApprovalPayload, ProjectFlockApprovalPayload,
CreateProjectFlockPayload, CreateProjectFlockPayload,
PeriodFlock,
ProjectFlock, ProjectFlock,
} from '@/types/api/production/project-flock'; } from '@/types/api/production/project-flock';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import TextInput from '@/components/input/TextInput';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import Collapse from '@/components/Collapse'; import Collapse from '@/components/Collapse';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant'; import { FLOCK_CATEGORY_OPTIONS } from '@/config/constant';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ProjectFlockKandangTable from './ProjectFlockKandangTable'; import ProjectFlockKandangTable from './ProjectFlockKandangTable';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
interface ProjectFlockFormProps { interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -52,23 +58,25 @@ const ProjectFlockForm = ({
}: ProjectFlockFormProps) => { }: ProjectFlockFormProps) => {
// State // State
const router = useRouter(); const router = useRouter();
const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] = const [projectFlockFormErrorMessage, setProjectFlockFormErrorMessage] =
useState(''); useState('');
const [selectedArea, setSelectedArea] = useState(''); const [selectedArea, setSelectedArea] = useState('');
const [selectedLocation, setSelectedLocation] = useState(''); const [selectedLocation, setSelectedLocation] = useState('');
const [disabledLocation, setDisabledLocation] = useState(true); const [disabledLocation, setDisabledLocation] = useState(
const [optionsLocation, setOptionsLocation] = useState<OptionType[]>([]); initialValues?.location?.id ? false : true
);
const [openSelectKandangs, setOpenSelectKandangs] = useState( const [openSelectKandangs, setOpenSelectKandangs] = useState(
initialValues?.kandangs && initialValues?.kandangs?.length > 0 initialValues?.kandangs && initialValues?.kandangs?.length > 0
); );
const [optionsKandang, setOptionsKandang] = useState<Kandang[]>( const [optionsKandang, setOptionsKandang] = useState<Kandang[]>(
initialValues?.kandangs ?? [] initialValues?.kandangs ?? []
); );
const [selectedFlock, setSelectedFlock] = useState<string | undefined>(
const [selectedFlock, setSelectedFlock] = useState<number>( initialValues?.flock_name?.slice(
initialValues?.flock?.id ?? 0 0,
initialValues?.flock_name?.lastIndexOf(' ')
) ?? ''
); );
const deleteModal = useModal(); const deleteModal = useModal();
@@ -77,7 +85,7 @@ const ProjectFlockForm = ({
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isApprovedDisabled, setIsApprovedDisabled] = useState( const [isApprovedDisabled, setIsApprovedDisabled] = useState(
initialValues?.approval.step_name == 'Pengajuan' ? false : true initialValues?.approval?.step_name == 'Pengajuan' ? false : true
); );
const [isRejectedDisabled, setIsRejectedDisabled] = const [isRejectedDisabled, setIsRejectedDisabled] =
useState(!isApprovedDisabled); useState(!isApprovedDisabled);
@@ -97,45 +105,39 @@ const ProjectFlockForm = ({
useEffect(() => { useEffect(() => {
if (initialValues?.approval?.step_name) { if (initialValues?.approval?.step_name) {
const approvedDisabled = initialValues.approval.step_name !== 'Pengajuan'; const pengajuanRejected =
initialValues.approval.step_number == 1 &&
initialValues.approval.action == 'REJECTED';
const approvedDisabled =
initialValues.approval.step_number !== 1 || pengajuanRejected;
setIsApprovedDisabled(approvedDisabled); setIsApprovedDisabled(approvedDisabled);
setIsRejectedDisabled(!approvedDisabled); setIsRejectedDisabled(!approvedDisabled || pengajuanRejected);
setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED'); setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED');
} }
}, [initialValues]); }, [initialValues]);
// Fetch Data // Fetch Data
const flockUrl = `${FlockApi.basePath}?${new URLSearchParams({ const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } =
search: '', useSelect(FlockApi.basePath, 'id', 'name');
}).toString()}`;
const { data: flocks, isLoading: isLoadingFlocks } = useSWR( const { options: optionsArea, isLoadingOptions: isLoadingAreas } = useSelect(
flockUrl, AreaApi.basePath,
FlockApi.getAllFetcher 'id',
'name'
); );
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ const { options: optionsLocation, isLoadingOptions: isLoadingLocations } =
search: '', useSelect(LocationApi.basePath, 'id', 'name', '', {
}).toString()}`; area_id:
const { data: areas, isLoading: isLoadingAreas } = useSWR( selectedArea != ''
areaUrl, ? selectedArea
AreaApi.getAllFetcher : ((initialValues?.area?.id ?? '') as string),
); });
const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({ const { options: optionsFcr, isLoadingOptions: isLoadingFcrs } = useSelect(
search: '', FcrApi.basePath,
area_id: selectedArea, 'id',
}).toString()}`; 'name'
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationUrl,
LocationApi.getAllFetcher
);
const fcrUrl = `${FcrApi.basePath}?${new URLSearchParams({
search: '',
}).toString()}`;
const { data: fcrs, isLoading: isLoadingFcrs } = useSWR(
fcrUrl,
FcrApi.getAllFetcher
); );
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
@@ -148,46 +150,21 @@ const ProjectFlockForm = ({
mutate: refreshKandang, mutate: refreshKandang,
} = useSWR(kandangUrl, KandangApi.getAllFetcher); } = useSWR(kandangUrl, KandangApi.getAllFetcher);
const getPeriodFlocksUrl = `flocks/${selectedFlock}/periods`; const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
`${selectedFlock?.toString()}/periods`,
const { data: periodFlocks, isLoading: isLoadingPeriodFlocks } = useSWR( () => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
getPeriodFlocksUrl,
() =>
ProjectFlockApi.customRequest<BaseApiResponse<PeriodFlock>, 'GET'>(
getPeriodFlocksUrl,
{ method: 'GET' }
)
); );
// Map Data to Options const {
const optionsArea = isResponseSuccess(areas) approvals,
? areas?.data.map((area) => ({ isLoading: approvalsLoading,
value: area.id, refresh: refreshApprovals,
label: area.name, } = useApprovalSteps({
})) latestApproval: initialValues?.approval,
: []; approvalLines: PROJECT_FLOCK_APPROVAL_LINE,
const optionsFcr = isResponseSuccess(fcrs) moduleName: 'PROJECT_FLOCKS',
? fcrs?.data.map((fcr) => ({ moduleId: initialValues?.id.toString() ?? '',
value: fcr.id, });
label: fcr.name,
}))
: [];
const optionsFlock = isResponseSuccess(flocks)
? flocks?.data.map((flock) => ({
value: flock.id,
label: flock.name,
}))
: [];
useEffect(() => {
if (isResponseSuccess(locations)) {
const options = locations.data.map((location) => ({
value: location.id,
label: location.name,
}));
setOptionsLocation(options);
}
}, [locations, setSelectedLocation]);
useEffect(() => { useEffect(() => {
if (isResponseSuccess(kandang)) { if (isResponseSuccess(kandang)) {
@@ -195,12 +172,25 @@ const ProjectFlockForm = ({
setOptionsKandang(kandang.data); setOptionsKandang(kandang.data);
setOpenSelectKandangs(true); setOpenSelectKandangs(true);
} else { } else {
formik.setFieldValue('kandang_ids', []);
setOptionsKandang([]); setOptionsKandang([]);
setOpenSelectKandangs(false); setOpenSelectKandangs(false);
const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id])
.map((id) => parseInt(id));
if (
JSON.stringify(kandang.data.map((k) => k.id)) !==
JSON.stringify(formik.values.kandang_ids)
) {
formik.setFieldValue('kandang_ids', []); formik.setFieldValue('kandang_ids', []);
setRowSelection({});
} else {
formik.setFieldValue('kandang_ids', selectedRowIds);
} }
} }
}, [kandang]); refreshPeriodFlocks();
}
}, [kandang, selectedLocation]);
useEffect(() => { useEffect(() => {
if (initialValues?.kandangs) { if (initialValues?.kandangs) {
refreshKandang(); refreshKandang();
@@ -211,7 +201,7 @@ const ProjectFlockForm = ({
); );
setRowSelection(newRowSelection); setRowSelection(newRowSelection);
} }
}, [initialValues, refreshKandang]); }, [initialValues, kandang]);
// Options Handler // Options Handler
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -232,9 +222,13 @@ const ProjectFlockForm = ({
}; };
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang_ids', []);
setSelectedLocation((val as OptionType)?.value as string); setSelectedLocation((val as OptionType)?.value as string);
optionChangeHandler(val, 'location'); optionChangeHandler(val, 'location');
formik.setFieldValue('kandang_ids', []); const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id])
.map((id) => parseInt(id));
formik.setFieldValue('kandang_ids', selectedRowIds);
}; };
const optionChangeHandler = ( const optionChangeHandler = (
@@ -291,17 +285,29 @@ const ProjectFlockForm = ({
// Formik InitialValue // Formik InitialValue
const formikInitialValues = useMemo<ProjectFlockFormValues>(() => { const formikInitialValues = useMemo<ProjectFlockFormValues>(() => {
const trimFlock =
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
) ?? '';
return { return {
name: initialValues?.name ?? '', flock: initialValues?.flock_name
flock: initialValues?.flock
? { ? {
value: initialValues.flock.id, value:
label: initialValues.flock.name, optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.value ?? 0,
label:
formType != 'detail'
? (optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.label ?? '')
: initialValues?.flock_name,
} }
: null, : null,
area: initialValues?.area area: initialValues?.area
? { ? {
value: initialValues.area.id, value: initialValues.area?.id,
label: initialValues.area.name, label: initialValues.area.name,
} }
: null, : null,
@@ -313,34 +319,115 @@ const ProjectFlockForm = ({
: null, : null,
fcr: initialValues?.fcr fcr: initialValues?.fcr
? { ? {
value: initialValues.fcr.id, value: initialValues.fcr?.id,
label: initialValues.fcr.name, label: initialValues.fcr.name,
} }
: null, : null,
location: initialValues?.location location: initialValues?.location
? { ? {
value: initialValues.location.id, value: initialValues.location?.id,
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
flock_id: initialValues?.flock?.id ?? 0, flock_name:
optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label ?? '',
area_id: initialValues?.area?.id ?? 0, area_id: initialValues?.area?.id ?? 0,
category: initialValues?.category as NonNullable< category: initialValues?.category as NonNullable<
'GROWING' | 'LAYING' | undefined 'GROWING' | 'LAYING' | undefined
>, >,
fcr_id: initialValues?.fcr?.id ?? 0, fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0, location_id: initialValues?.location?.id ?? 0,
period: initialValues?.period ?? 0,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as ( kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number | number
| undefined | undefined
)[], )[],
}; };
}, [initialValues]); }, [initialValues, optionsFlock]);
// Formik // Formik
const formik = useFormik<ProjectFlockFormValues>({ const formik = useFormik<ProjectFlockFormValues>({
initialValues: formikInitialValues, initialValues: {
flock: initialValues?.flock_name
? {
value:
optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.value ?? 0,
label:
formType != 'detail'
? (optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label ?? '')
: initialValues?.flock_name,
}
: null,
area: initialValues?.area
? {
value: initialValues.area?.id,
label: initialValues.area.name,
}
: null,
category_option: initialValues?.category
? {
value: initialValues.category,
label: initialValues.category,
}
: null,
fcr: initialValues?.fcr
? {
value: initialValues.fcr?.id,
label: initialValues.fcr.name,
}
: null,
location: initialValues?.location
? {
value: initialValues.location?.id,
label: initialValues.location.name,
}
: null,
flock_name:
formType != 'detail'
? optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label
: (initialValues?.flock_name ?? ''),
area_id: initialValues?.area?.id ?? 0,
category: initialValues?.category as NonNullable<
'GROWING' | 'LAYING' | undefined
>,
fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number
| undefined
)[],
} as ProjectFlockFormValues,
enableReinitialize: true, enableReinitialize: true,
validationSchema: validationSchema:
formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema, formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema,
@@ -350,12 +437,11 @@ const ProjectFlockForm = ({
onSubmit: async (values) => { onSubmit: async (values) => {
setProjectFlockFormErrorMessage(''); setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = { const payload: CreateProjectFlockPayload = {
flock_id: values.flock_id as number, flock_name: values.flock_name as string,
area_id: values.area_id as number, area_id: values.area_id as number,
category: values.category as string, category: values.category as string,
fcr_id: values.fcr_id as number, fcr_id: values.fcr_id as number,
location_id: values.location_id as number, location_id: values.location_id as number,
period: values.period as number,
kandang_ids: values.kandang_ids as number[], kandang_ids: values.kandang_ids as number[],
}; };
@@ -377,21 +463,19 @@ const ProjectFlockForm = ({
useEffect(() => { useEffect(() => {
if (formType == 'detail') { if (formType == 'detail') {
formik.setFieldValue('area', { formik.setFieldValue('area', {
value: initialValues?.area.id, value: initialValues?.area?.id,
label: initialValues?.area.name, label: initialValues?.area?.name,
}); });
formik.setFieldValue('area_id', initialValues?.area_id); formik.setFieldValue('area_id', initialValues?.area_id);
if (initialValues?.area_id) { if (initialValues?.area_id) {
setSelectedArea(initialValues?.area_id.toString() as string); setSelectedArea(initialValues?.area_id.toString() as string);
} }
formik.setFieldValue('period', initialValues?.period);
} }
}, [initialValues, setSelectedArea, formType]); }, [initialValues, setSelectedArea, formType]);
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues]);
// Aktifkan lokasi jika formType = 'detail' // Aktifkan lokasi jika formType = 'detail'
useEffect(() => { useEffect(() => {
@@ -402,22 +486,18 @@ const ProjectFlockForm = ({
// Set lokasi otomatis berdasarkan initialValues saat formType = 'detail' // Set lokasi otomatis berdasarkan initialValues saat formType = 'detail'
useEffect(() => { useEffect(() => {
if (formType != 'add' && initialValues?.location?.id) { if (formType != 'add') {
if (initialValues?.location?.id) {
setSelectedLocation(initialValues.location?.id.toString()); setSelectedLocation(initialValues.location?.id.toString());
setDisabledLocation(false); // biar dropdown lokasi aktif juga setDisabledLocation(false); // biar dropdown lokasi aktif juga
} }
}
}, [formType, initialValues]); }, [formType, initialValues]);
useEffect(() => { useEffect(() => {
formik.validateForm(); formik.validateForm();
}, [formik.values]); }, [formik.values]);
useEffect(() => {
if (isResponseSuccess(periodFlocks)) {
formik.setFieldValue('period', periodFlocks.data.next_period);
}
}, [periodFlocks]);
useEffect(() => { useEffect(() => {
const selectedRowIds = Object.keys(rowSelection) const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id]) .filter((id) => rowSelection[id])
@@ -445,41 +525,25 @@ const ProjectFlockForm = ({
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const confirmationModalClickHandler = async ({ const confirmApprovalHandler = async (
action = 'APPROVED', notes: string,
}: { approvalAction: 'REJECTED' | 'APPROVED'
action: 'APPROVED' | 'REJECTED'; ) => {
}) => {
if (initialValues?.id === undefined) return; if (initialValues?.id === undefined) return;
setIsApproveLoading(true); setIsApproveLoading(true);
const approveProjectFlockRes = await ProjectFlockApi.customRequest<
BaseApiResponse<ProjectFlock>,
ProjectFlockApprovalPayload
>(`/approvals`, {
method: 'POST',
payload: {
action: action,
approvable_ids: [initialValues.id],
},
});
if (isResponseSuccess(approveProjectFlockRes)) { const approvalRes =
if (refreshProjectFlocks) { approvalAction == 'APPROVED'
await refreshProjectFlocks(); ? await ProjectFlockApi.approve(initialValues?.id, notes)
: await ProjectFlockApi.reject(initialValues?.id, notes);
if (isResponseSuccess(approvalRes)) {
refreshProjectFlocks?.();
toast.success(approvalRes.message as string);
} }
// if (action == 'APPROVED') { if (isResponseError(approvalRes)) {
// setIsApprovedDisabled(true); toast.error(approvalRes?.message as string);
// setIsRejectedDisabled(false);
// }
// if (action == 'REJECTED') {
// setIsRejectedDisabled(true);
// setIsApprovedDisabled(false);
// }
toast.success(approveProjectFlockRes.message as string);
}
if (isResponseError(approveProjectFlockRes)) {
toast.error(approveProjectFlockRes?.message as string);
} }
refreshApprovals();
confirmModal.closeModal(); confirmModal.closeModal();
setIsApproveLoading(false); setIsApproveLoading(false);
}; };
@@ -522,6 +586,9 @@ const ProjectFlockForm = ({
</div> </div>
</div> </div>
)} )}
{approvals && !approvalsLoading && formType == 'detail' && (
<ApprovalSteps approvals={approvals} />
)}
{formType == 'detail' && ( {formType == 'detail' && (
<div className='w-full flex flex-col sm:flex-row gap-2 py-4'> <div className='w-full flex flex-col sm:flex-row gap-2 py-4'>
<Button <Button
@@ -554,6 +621,21 @@ const ProjectFlockForm = ({
<Icon icon='mdi:times' width={24} height={24} /> <Icon icon='mdi:times' width={24} height={24} />
Reject Reject
</Button> </Button>
{initialValues?.approval?.step_number == 2 && (
<Button
variant='outline'
color='success'
className='w-full sm:w-fit'
onClick={() => {
router.push(
`/production/project-flock/chickin/add?projectFlockId=${initialValues?.id}`
);
}}
>
<Icon icon='mdi:home-import-outline' width={18} height={18} />
Chickin
</Button>
)}
</div> </div>
)} )}
<form <form
@@ -564,7 +646,6 @@ const ProjectFlockForm = ({
<div className='card bg-base-100 shadow w-full mb-6'> <div className='card bg-base-100 shadow w-full mb-6'>
<div className='card-body'> <div className='card-body'>
<div className='card-title mb-4'>Informasi Umum</div> <div className='card-title mb-4'>Informasi Umum</div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-2 gap-4'>
<SelectInput <SelectInput
required required
@@ -583,17 +664,31 @@ const ProjectFlockForm = ({
<SelectInput <SelectInput
required required
label='Flock' label='Flock'
value={formik.values.flock as OptionType} value={
formik.values.flock_name
? ({
label: formik.values.flock_name,
value: optionsFlock.find((flock) => {
return flock.label === formik.values.flock_name;
})?.value,
} as OptionType)
: undefined
}
onChange={(val) => { onChange={(val) => {
optionChangeHandler(val, 'flock'); optionChangeHandler(val, 'flock');
setSelectedFlock((val as OptionType)?.value as number); setSelectedFlock((val as OptionType)?.label);
formik.setFieldValue(
'flock_name',
(val as OptionType)?.label
);
}} }}
options={optionsFlock} options={optionsFlock}
isLoading={isLoadingFlocks} isLoading={isLoadingFlocks}
isError={ isError={
formik.touched.flock_id && Boolean(formik.errors.flock_id) formik.touched.flock_name &&
Boolean(formik.errors.flock_name)
} }
errorMessage={formik.errors.flock_id as string} errorMessage={formik.errors.flock_name as string}
isClearable isClearable
isDisabled={formType === 'detail'} isDisabled={formType === 'detail'}
/> />
@@ -602,7 +697,11 @@ const ProjectFlockForm = ({
label='Lokasi' label='Lokasi'
value={formik.values.location as OptionType} value={formik.values.location as OptionType}
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={optionsLocation} options={
selectedArea != '' || initialValues?.area?.id
? optionsLocation
: []
}
isLoading={isLoadingLocations} isLoading={isLoadingLocations}
isError={ isError={
formik.touched.location_id && formik.touched.location_id &&
@@ -641,22 +740,6 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType === 'detail'} isDisabled={formType === 'detail'}
/> />
<TextInput
required
type='number'
name='period'
label='Periode'
placeholder='Masukkan periode yang project'
value={formik.values.period as number}
onChange={formik.handleChange}
isError={
formik.touched.period && Boolean(formik.errors.period)
}
errorMessage={formik.errors.period as string}
readOnly={formType === 'detail'}
disabled={true}
isLoading={isLoadingPeriodFlocks}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -690,11 +773,15 @@ const ProjectFlockForm = ({
<span className='loading loading-dots loading-xl'></span> <span className='loading loading-dots loading-xl'></span>
)} )}
<ProjectFlockKandangTable <ProjectFlockKandangTable
listPeriods={
isResponseSuccess(periodFlocks) ? periodFlocks.data : []
}
listKandang={optionsKandang} listKandang={optionsKandang}
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
selectedIds={formik.values.kandang_ids} selectedIds={formik.values.kandang_ids}
formType={formType} formType={formType}
initialValues={initialValues?.kandangs ?? []}
/> />
</div> </div>
</Collapse> </Collapse>
@@ -716,7 +803,10 @@ const ProjectFlockForm = ({
type='submit' type='submit'
color='primary' color='primary'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={
!formik.isValid || formik.isSubmitting
// TODO: Add logic && ketika nilai kandang_ids sudah beda dari initial values
}
className='px-4' className='px-4'
> >
Submit Submit
@@ -726,7 +816,25 @@ const ProjectFlockForm = ({
</div> </div>
</form> </form>
{formType != 'add' && ( {formType != 'add' && (
<div className='w-full'> <div className='flex flex-row gap-2 mb-6'>
{formType != 'edit' && (
<Button
onClick={() => {
router.push(
`/production/project-flock/detail/edit?projectFlockId=${initialValues?.id}`
);
}}
color='warning'
>
<Icon
icon='mdi:pencil-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Edit
</Button>
)}
<Button <Button
onClick={() => { onClick={() => {
if (initialValues?.id) { if (initialValues?.id) {
@@ -750,7 +858,7 @@ const ProjectFlockForm = ({
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${initialValues?.flock?.name} - ${initialValues?.area?.name})?`} text={`Apakah anda yakin ingin menghapus data Project Flock ini (${initialValues?.flock_name} - ${initialValues?.area?.name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -762,12 +870,12 @@ const ProjectFlockForm = ({
}} }}
/> />
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'} type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${ text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject' approvalAction == 'APPROVED' ? 'approve' : 'reject'
} Project Flock berikut? (${initialValues?.flock?.name} - ${ } Project Flock berikut? (${initialValues?.flock_name} - ${
initialValues?.area?.name initialValues?.area?.name
})?`} })?`}
secondaryButton={{ secondaryButton={{
@@ -777,10 +885,8 @@ const ProjectFlockForm = ({
text: 'Ya', text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error', color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading, isLoading: isApproveLoading,
onClick: () => { onClick: (notes) => {
confirmationModalClickHandler({ confirmApprovalHandler(notes, approvalAction);
action: approvalAction,
});
}, },
}} }}
/> />
@@ -5,24 +5,40 @@ import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { OnChangeFn } from '@tanstack/react-table'; import { ProjectFlockPeriods } from '@/types/api/production/project-flock';
import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react';
const ProjectFlockKandangTable = ({ const ProjectFlockKandangTable = ({
listPeriods,
listKandang, listKandang,
rowSelection, rowSelection,
setRowSelection, setRowSelection,
selectedIds, selectedIds,
initialValues,
formType = 'add', formType = 'add',
}: { }: {
listPeriods: ProjectFlockPeriods;
listKandang: Kandang[]; listKandang: Kandang[];
rowSelection: Record<string, boolean>; rowSelection: Record<string, boolean>;
setRowSelection: OnChangeFn<Record<string, boolean>>; setRowSelection: OnChangeFn<Record<string, boolean>>;
selectedIds: (number | undefined)[]; selectedIds: (number | undefined)[];
initialValues?: Kandang[];
formType: 'add' | 'edit' | 'detail'; formType: 'add' | 'edit' | 'detail';
}) => { }) => {
console.log('selectedIds'); const initialKandangIdSet = useMemo(() => {
console.log(selectedIds); return initialValues?.map((k) => k.id) ?? [];
}, [initialValues]);
const isRowEnabled = (row: Row<Kandang>) => {
const isDisabled =
!initialKandangIdSet.includes(row.original.id) &&
(row.original.status == 'ACTIVE' ||
row.original.status == 'PENGAJUAN' ||
formType == 'detail');
return !isDisabled;
};
return ( return (
<>
<Table<Kandang> <Table<Kandang>
data={listKandang} data={listKandang}
columns={[ columns={[
@@ -30,25 +46,25 @@ const ProjectFlockKandangTable = ({
id: 'select', id: 'select',
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter( // 1. Filter semua baris dengan logika yang sama persis seperti di cell
(row) => const selectableRows = allRows.filter(isRowEnabled);
row.original.status == 'NON_ACTIVE' ||
row.original.status == 'PENGAJUAN'
);
// 2. Cek apakah SEMUA baris yang BISA DIPILIH sudah terpilih
const allSelected = const allSelected =
selectableRows.every((row) => row.getIsSelected()) && selectableRows.length > 0 &&
selectableRows.length != 0 && selectableRows.every((row) => row.getIsSelected());
formType != 'detail';
// 3. Cek apakah BEBERAPA baris yang BISA DIPILIH sudah terpilih
const someSelected = const someSelected =
selectableRows.some((row) => row.getIsSelected()) && selectableRows.some((row) => row.getIsSelected()) &&
!allSelected && !allSelected;
formType != 'detail';
// 4. Fungsi toggle HANYA akan mentoggle baris yang BISA DIPILIH
const toggleSelectableRows = () => { const toggleSelectableRows = () => {
const shouldSelect = !allSelected; const shouldSelect = !allSelected;
selectableRows.forEach((row) => row.toggleSelected(shouldSelect)); selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
);
}; };
return ( return (
@@ -59,11 +75,7 @@ const ProjectFlockKandangTable = ({
indeterminate={someSelected} indeterminate={someSelected}
onChange={toggleSelectableRows} onChange={toggleSelectableRows}
disabled={ disabled={
listKandang.filter( selectableRows.length === 0 || formType == 'detail'
(kandang) =>
kandang.status == 'NON_ACTIVE' ||
kandang.status == 'PENGAJUAN'
).length == 0 || formType == 'detail'
} }
/> />
</div> </div>
@@ -77,13 +89,13 @@ const ProjectFlockKandangTable = ({
(row.getIsSelected() && (row.getIsSelected() &&
(row.original.status == 'NON_ACTIVE' || (row.original.status == 'NON_ACTIVE' ||
row.original.status == 'PENGAJUAN')) || row.original.status == 'PENGAJUAN')) ||
selectedIds.includes(row.original.id) (selectedIds && selectedIds.includes(row.original.id))
} }
disabled={ disabled={
!row.getCanSelect() || formType == 'detail' ||
(row.original.status != 'NON_ACTIVE' && (!initialKandangIdSet.includes(row.original.id) &&
row.original.status != 'PENGAJUAN') || (row.original.status == 'ACTIVE' ||
formType == 'detail' row.original.status == 'PENGAJUAN'))
} }
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
@@ -121,6 +133,23 @@ const ProjectFlockKandangTable = ({
); );
}, },
}, },
{
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;
return period?.period ?? '-';
},
},
{ {
accessorFn: (row) => row.pic?.name, accessorFn: (row) => row.pic?.name,
header: 'Penanggung Jawab', header: 'Penanggung Jawab',
@@ -143,6 +172,7 @@ const ProjectFlockKandangTable = ({
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
/> />
</>
); );
}; };
File diff suppressed because it is too large Load Diff
@@ -1,212 +1,320 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import {
import { Recording } from '@/types/api/production/recording'; Recording,
CreateGrowingRecordingPayload,
CreateLayingRecordingPayload,
CreateEggPayload,
CreateGradingPayload,
} from '@/types/api/production/recording';
export const RecordingFormSchema = Yup.object({ type RecordingGrowingFormSchemaType = {
flock: Yup.object({ project_flock_kandang: {
value: Yup.number().min(1).required(), value: number;
label: Yup.string().required(), label: string;
}).nullable(), } | null;
flock_id: Yup.number() project_flock_kandang_id: number;
.default(0) body_weights: {
.typeError('Flock wajib diisi!') weight: number | string;
.test( avg_weight: number | string;
'is-valid-flock', qty: number | string;
'Flock wajib diisi!', }[];
(value) => value !== undefined && value !== null && value > 0 stocks: {
) product_warehouse_id: number;
.required('Flock wajib diisi!'), qty: number | string;
location: Yup.object({ }[];
value: Yup.number().min(1).required(), depletions: {
label: Yup.string().required(), product_warehouse_id: number;
}).nullable(), qty: number | string;
location_id: Yup.number() }[];
.default(0) };
.typeError('Lokasi wajib diisi!')
.test( type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
'is-valid-location', eggs: {
'Lokasi wajib diisi!', product_warehouse_id: number;
(value) => value !== undefined && value !== null && value > 0 qty: number | string;
) }[];
.required('Lokasi wajib diisi!'), };
coop: Yup.object({
value: Yup.number().min(1).required(), type RecordingGradingFormSchemaType = {
label: Yup.string().required(), eggs_grading: {
}).nullable(), recording_egg_id: number;
coop_id: Yup.number() grade: string;
.default(0) qty: number | string;
.typeError('Kandang wajib diisi!') }[];
.test( };
'is-valid-coop',
'Kandang wajib diisi!', export type BodyWeightSchema = {
(value) => value !== undefined && value !== null && value > 0 weight: number | string;
) avg_weight: number | string;
.required('Kandang wajib diisi!'), qty: number | string;
recording_date: Yup.date() };
.required('Tanggal recording wajib diisi')
.typeError('Format tanggal tidak valid'), export type StockSchema = {
feed_data: Yup.array() product_warehouse_id: number;
.of( qty: number | string;
Yup.object({ };
feed_id: Yup.string().required('Nama pakan wajib diisi!'),
feed_qty: Yup.mixed<number | ''>().notRequired(), export type DepletionSchema = {
feed_stock: Yup.number() product_warehouse_id: number;
.required('Jumlah pakan yang digunakan wajib diisi!') qty: number | string;
.min(1, 'Jumlah pakan minimal 1!') };
.typeError('Jumlah pakan yang digunakan harus berupa angka!')
.test( export type EggSchema = {
'is-not-exceed-qty', product_warehouse_id: number;
'Jumlah pakan yang digunakan tidak boleh melebihi stok tersedia!', qty: number | string;
function (value) { };
const { feed_qty } = this.parent;
if (value === undefined) return true; const BodyWeightObjectSchema: Yup.ObjectSchema<BodyWeightSchema> = Yup.object({
if ( weight: Yup.number()
feed_qty === undefined || .required('Berat ayam total wajib diisi!')
feed_qty === '' || .min(1, 'Berat ayam total minimal 1 gram!')
typeof feed_qty !== 'number' .typeError('Berat ayam total harus berupa angka!'),
) avg_weight: Yup.number()
return true; .required('Berat ayam rata-rata wajib diisi!')
return value <= feed_qty; .typeError('Berat ayam rata-rata harus berupa angka!'),
} qty: Yup.number()
),
})
)
.min(1, 'Minimal harus ada 1 data pakan!')
.required('Data pakan wajib diisi!'),
body_weight: Yup.array()
.of(
Yup.object({
chicken_weight: Yup.number()
.required('Berat ayam wajib diisi!')
.min(1, 'Berat ayam minimal 1 gram!')
.typeError('Berat ayam harus berupa angka!'),
chicken_count: Yup.number()
.required('Jumlah ayam wajib diisi!') .required('Jumlah ayam wajib diisi!')
.min(1, 'Jumlah ayam minimal 1 ekor!') .min(1, 'Jumlah ayam minimal 1 ekor!')
.typeError('Jumlah ayam harus berupa angka!'), .typeError('Jumlah ayam harus berupa angka!'),
average_chicken_weight: Yup.number() });
.required('Rata-rata berat ayam wajib diisi!')
.min(1, 'Rata-rata berat ayam minimal 1 gram!') const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
.typeError('Rata-rata berat ayam harus berupa angka!'), product_warehouse_id: Yup.number()
}) .required('Produk wajib diisi!')
) .min(1, 'Produk wajib diisi!')
.min(1, 'Minimal harus ada 1 data bobot badan!') .typeError('Produk harus berupa angka!'),
.required('Data bobot badan wajib diisi!'), qty: Yup.number()
vaccination: Yup.array() .required('Jumlah penggunaan wajib diisi!')
.of( .min(1, 'Jumlah penggunaan tidak boleh 0!')
.typeError('Jumlah penggunaan harus berupa angka!'),
});
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number()
.required('Produk depletions wajib diisi!')
.min(1, 'Produk depletions wajib diisi!')
.typeError('Produk depletions harus berupa angka!'),
qty: Yup.number()
.required('Jumlah depletions wajib diisi!')
.min(1, 'Jumlah depletions minimal 1!')
.typeError('Jumlah depletions harus berupa angka!'),
});
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.number()
.required('Kondisi telur wajib diisi!')
.min(1, 'Kondisi telur wajib diisi!')
.typeError('Kondisi telur harus berupa angka!'),
qty: Yup.number()
.required('Jumlah telur wajib diisi!')
.min(1, 'Jumlah telur tidak boleh 0!')
.typeError('Jumlah telur harus berupa angka!'),
});
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
Yup.object({ Yup.object({
vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'), project_flock_kandang: Yup.object({
total_stock: Yup.mixed<number | ''>().notRequired(), value: Yup.number().min(1).required(),
used_stock: Yup.number() label: Yup.string().required(),
.required('Jumlah vaksin yang digunakan wajib diisi!') }).nullable(),
.min(1, 'Jumlah vaksin minimal 1!') project_flock_kandang_id: Yup.number()
.typeError('Jumlah vaksin yang digunakan harus berupa angka!') .default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test( .test(
'is-not-exceed-total', 'is-valid-project-flock-kandang',
'Jumlah vaksin yang digunakan tidak boleh melebihi stok tersedia!', 'Project Flock Kandang wajib diisi!',
function (value) { (value) => value !== undefined && value !== null && value > 0
const { total_stock } = this.parent;
if (value === undefined) return true;
if (
total_stock === undefined ||
total_stock === '' ||
typeof total_stock !== 'number'
) )
.required('Project Flock Kandang wajib diisi!')
.test(
'not-already-recorded',
'Project Flock ini sudah direcord hari ini!',
function (value) {
const recordedProjectFlockIds = this.options.context
?.recordedProjectFlockIds as Set<number>;
const formType = this.options.context?.type as
| 'add'
| 'edit'
| 'detail';
if (formType !== 'add') return true;
if (value && recordedProjectFlockIds?.has(value)) {
return false;
}
return true; return true;
return value <= total_stock;
} }
), ),
}) body_weights: Yup.array()
.of(BodyWeightObjectSchema)
.min(1, 'Minimal harus ada 1 data bobot badan!')
.required('Data bobot badan wajib diisi!'),
stocks: Yup.array()
.of(StockObjectSchema)
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array()
.of(DepletionObjectSchema)
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
});
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
RecordingGrowingFormSchema.shape({
eggs: Yup.array()
.of(EggObjectSchema)
.min(1, 'Minimal harus ada 1 data telur!')
.required('Data telur wajib diisi!'),
});
export const UpdateRecordingGrowingFormSchema =
RecordingGrowingFormSchema.shape({
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
) )
.min(1, 'Minimal harus ada 1 data vaksinasi!') .required('Project Flock Kandang wajib diisi!'),
.required('Data vaksinasi wajib diisi!'), });
mortality: Yup.array()
export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Project Flock Kandang wajib diisi!'),
});
export const RecordingGradingFormSchema: Yup.ObjectSchema<RecordingGradingFormSchemaType> =
Yup.object({
eggs_grading: Yup.array()
.of( .of(
Yup.object({ Yup.object({
condition: Yup.mixed<string>() recording_egg_id: Yup.number()
.oneOf( .required('Recording Egg ID wajib diisi!')
RECORDING_FLAG_OPTIONS.map((opt) => opt.value), .min(1, 'Recording Egg ID minimal 1!')
'Kondisi tidak valid!' .typeError('Recording Egg ID harus berupa angka!'),
) grade: Yup.string()
.required('Kondisi wajib diisi!'), .required('Grade telur wajib diisi!')
count: Yup.number() .typeError('Grade telur harus berupa string!'),
.required('Jumlah mortalitas wajib diisi!') qty: Yup.number()
.min(1, 'Jumlah mortalitas minimal 1 ekor!') .required('Jumlah telur wajib diisi!')
.typeError('Jumlah mortalitas harus berupa angka!'), .min(1, 'Jumlah telur minimal 1!')
.typeError('Jumlah telur harus berupa angka!'),
}) })
) )
.min(1, 'Minimal harus ada 1 data mortalitas!') .min(1, 'Minimal harus ada 1 data grading telur!')
.required('Data mortalitas wajib diisi!'), .required('Data grading telur wajib diisi!'),
}); });
export const UpdateRecordingFormSchema = RecordingFormSchema; export const UpdateRecordingGradingFormSchema = RecordingGradingFormSchema;
export type RecordingFormValues = Yup.InferType<typeof RecordingFormSchema>; export type RecordingGrowingFormValues = Yup.InferType<
typeof RecordingGrowingFormSchema
>;
export const getRecordingFormInitialValues = ( export type RecordingLayingFormValues = Yup.InferType<
initialValues?: Recording typeof RecordingLayingFormSchema
): RecordingFormValues => ({ >;
flock: initialValues?.flock
export type RecordingGradingFormValues = Yup.InferType<
typeof RecordingGradingFormSchema
>;
type RecordingFormData = Partial<Recording> & {
body_weights?: CreateGrowingRecordingPayload['body_weights'];
stocks?: CreateGrowingRecordingPayload['stocks'] | Recording['stocks'];
depletions?:
| CreateGrowingRecordingPayload['depletions']
| Recording['depletions'];
eggs?: CreateLayingRecordingPayload['eggs'] | Recording['eggs'];
project_flock_kandang_id?: number;
project_flock_category?: string;
};
export const getRecordingGrowingFormInitialValues = (
initialValues?: RecordingFormData
): RecordingGrowingFormValues => ({
project_flock_kandang: initialValues?.project_flock_kandang_id
? { ? {
value: initialValues.flock.id, value: initialValues.project_flock_kandang_id,
label: initialValues.flock.name, label: `Project Flock #${initialValues.project_flock_kandang_id}`,
} }
: null, : null,
flock_id: initialValues?.flock?.id ?? 0, project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0,
location: initialValues?.location body_weights: initialValues?.body_weights?.map(
? { (bw: NonNullable<CreateGrowingRecordingPayload['body_weights']>[0]) => ({
value: initialValues.location.id, weight: bw.avg_weight * bw.qty,
label: initialValues.location.name, avg_weight: bw.avg_weight,
} qty: bw.qty,
: null, })
location_id: initialValues?.location?.id ?? 0, ) ?? [
coop: initialValues?.coop
? {
value: initialValues.coop.id,
label: initialValues.coop.name,
}
: null,
coop_id: initialValues?.coop?.id ?? 0,
recording_date: initialValues?.recording_date
? new Date(initialValues.recording_date)
: new Date(),
feed_data: initialValues?.feed_data
? initialValues.feed_data.map((feed) => ({
feed_id: feed.feed_name,
feed_qty: feed.feed_qty,
feed_stock: feed.feed_stock,
}))
: [
{ {
feed_id: '', weight: '',
feed_qty: '', avg_weight: '',
feed_stock: 0, qty: '',
}, },
], ],
body_weight: initialValues?.body_weight ?? [ stocks: initialValues?.stocks?.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty:
(stock as { qty?: number; usage_amount?: number }).qty ||
(stock as { qty?: number; usage_amount?: number }).usage_amount ||
'',
})) ?? [
{ {
chicken_weight: 0, product_warehouse_id: 0,
chicken_count: 0, qty: '',
average_chicken_weight: 0,
}, },
], ],
vaccination: initialValues?.vaccination depletions: initialValues?.depletions?.map(
? initialValues.vaccination.map((vaccine) => ({ (
vaccine_id: vaccine.vaccine_name, depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
total_stock: vaccine.total_stock, ) => ({
used_stock: vaccine.used_stock, product_warehouse_id: depletion.product_warehouse_id,
})) qty: depletion.qty,
: [ })
) ?? [
{ {
vaccine_id: '', product_warehouse_id: 0,
total_stock: '', qty: '',
used_stock: 0, },
}, ],
], });
mortality: initialValues?.mortality ?? [
{ export const getRecordingLayingFormInitialValues = (
condition: '', initialValues?: RecordingFormData
count: 0, ): RecordingLayingFormValues => ({
...getRecordingGrowingFormInitialValues(initialValues),
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: egg.product_warehouse_id,
qty: egg.qty,
})) ?? [
{
product_warehouse_id: 0,
qty: '',
},
],
});
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: '',
}, },
], ],
}); });

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