Compare commits

..

439 Commits

Author SHA1 Message Date
ValdiANS bac3f30ce3 chore: update Table component 2025-12-04 23:09:08 +07:00
ValdiANS be725d42c3 chore: add Size type 2025-12-04 22:46:26 +07:00
ValdiANS b37c3f87b0 chore: set color for menu foreground and background 2025-12-04 22:46:18 +07:00
ValdiANS ae4c17b391 chore: create isPathActive helper 2025-12-04 22:45:57 +07:00
ValdiANS 48dd6d7218 chore: update MAIN_DRAWER_LINKS structure 2025-12-04 22:45:48 +07:00
ValdiANS cee3d4ba90 chore: create SidebarMenu component 2025-12-04 22:45:29 +07:00
ValdiANS a8d7fdc30d chore: update Menu component 2025-12-04 22:45:20 +07:00
ValdiANS 2bb2da74e6 chore: update CheckboxInput component 2025-12-04 22:45:13 +07:00
ValdiANS fd024fdb8f chore: update Pagination component 2025-12-04 22:44:43 +07:00
ValdiANS 79a89ea193 chore: use SidebarMenu component 2025-12-04 22:44:17 +07:00
ValdiANS 611655e408 chore: update gitlab-ci 2025-12-04 22:42:57 +07:00
ValdiANS 702943c55c chore: update next, daisyui, and eslint-config-next library 2025-12-04 22:36:22 +07:00
kris 58532881f4 Update .gitlab-ci.yml update env 2025-11-28 13:06:03 +00:00
kris 4073f4dfde Update .gitlab-ci.yml update env 2025-11-28 12:47:47 +00:00
kris 94e2d71dba Update .gitlab-ci.yml file 2025-11-28 10:15:49 +00:00
kris 944f479e2d Update .gitlab-ci.yml file 2025-11-28 10:02:17 +00:00
kris 5046d687b5 Update .gitlab-ci.yml file 2025-11-28 09:54:57 +00:00
kris 711deda6a8 Update .gitlab-ci.yml file 2025-11-28 09:38:44 +00:00
Adnan Zahir 029be31020 Merge branch 'feat/FE/US-164/expense-realization' into 'development'
[FEAT/FE][US#164] Expense Realization

See merge request mbugroup/lti-web-client!67
2025-11-28 15:40:33 +07:00
Rivaldi A N S ed7563a028 Merge branch 'feat/FE/US-164/TASK-200-204-205-206-207-expense-realization' into 'feat/FE/US-164/expense-realization'
[FEAT/FE][US#164/TASK#200-204-205-206-207] Expense Realization

See merge request mbugroup/lti-web-client!66
2025-11-28 03:41:26 +00:00
ValdiANS f82ca4f959 chore(FE-195): adjust RowOptionsMenu type 2025-11-28 10:32:00 +07:00
ValdiANS 0cc01ae738 feat(FE-196,199): create ExpensePDF component 2025-11-28 10:31:27 +07:00
ValdiANS 1de743a404 feat(FE-196,199): create ExpensePDFButton component 2025-11-28 10:31:08 +07:00
ValdiANS 68c1e76a4a feat(FE-196,199): add Expense PDF Preview button 2025-11-28 10:30:26 +07:00
ValdiANS 2001cdb843 chore(FE-205): adjust content styling 2025-11-28 10:29:36 +07:00
ValdiANS b8590040ff chore: remove unnecessary code 2025-11-28 10:28:33 +07:00
ValdiANS 909aa3357c chore: add MBU logo 2025-11-28 10:24:56 +07:00
ValdiANS 507543eff8 chore: remove unnecessary code 2025-11-27 09:38:26 +07:00
ValdiANS 8dc6b3d1db Merge branch 'development' into feat/FE/US-164/TASK-200-204-205-206-207-expense-realization 2025-11-26 13:44:01 +07:00
Adnan Zahir 22ce1b1142 Merge branch 'feat/FE/US-162/purchase-order' into 'development'
[FEAT/FE][US#161|US#162] Add Feature Purchase Request and Purchase Order

See merge request mbugroup/lti-web-client!65
2025-11-26 12:47:50 +07:00
Rivaldi A N S e126ab4a0e Merge branch 'feat/FE/US-161/TASK-208-212-slicing-ui-and-validation-create-purchase-request-form' into 'feat/FE/US-162/purchase-order'
[FEAT/FE][US#161|US#162] Add Feature Purchase Request and Purchase Order

See merge request mbugroup/lti-web-client!61
2025-11-26 03:10:06 +00:00
rstubryan 7685be0c7b Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-161/TASK-208-212-slicing-ui-and-validation-create-purchase-request-form 2025-11-26 09:43:52 +07:00
Adnan Zahir 8b72f58467 Merge branch 'fix/FE/US-160/marketing-delivery-order' into 'development'
[FIX/FE][US#160] Fixing Missing Component UI DebounceTextArea

See merge request mbugroup/lti-web-client!64
2025-11-26 09:43:16 +07:00
Rivaldi A N S 87c25a8bc4 Merge branch 'dev/randy' into 'fix/FE/US-160/marketing-delivery-order'
[FIX/FE][US#160/TASK#220] Fixing Missing Component UI DebounceTextArea

See merge request mbugroup/lti-web-client!63
2025-11-26 02:15:14 +00:00
randy-ar 1e4c826a0a fix(FE): fixing error missing component DebounceTextArea 2025-11-26 01:27:00 +07:00
rstubryan 6d3632a385 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-161/TASK-208-212-slicing-ui-and-validation-create-purchase-request-form 2025-11-25 11:30:49 +07:00
Adnan Zahir d75ac635df Merge branch 'feat/FE/US-160/marketing-delivery-order' into 'development'
[FE/FE][US#160] Marketing - Delivery Order

See merge request mbugroup/lti-web-client!62
2025-11-25 11:30:17 +07:00
Rivaldi A N S 352fd701cb Merge branch 'dev/randy' into 'feat/FE/US-160/marketing-delivery-order'
[FE/FE][US#160/TASK#179-181-220-272] Adding Feature Delivery Order

See merge request mbugroup/lti-web-client!60
2025-11-25 04:22:36 +00:00
ValdiANS 2a97f9d504 Merge branch 'development' into feat/FE/US-164/TASK-200-204-205-206-207-expense-realization 2025-11-25 10:55:52 +07:00
ValdiANS b805fb4ae1 chore(FE-196): use useApprovalSteps hook 2025-11-25 10:48:27 +07:00
ValdiANS 642f966985 chore: adjust ApprovalSteps component 2025-11-25 10:47:34 +07:00
ValdiANS c3e4d4c630 chore(FE-199,207): adjust expense type according to the API need 2025-11-25 09:25:40 +07:00
ValdiANS b616f28c95 feat: add interceptor to axiosClient to redirect to login page if the user is logged out 2025-11-25 09:24:46 +07:00
ValdiANS 334202569c chore(FE-199,207): integrate ExpenseApiService to API 2025-11-25 09:24:12 +07:00
ValdiANS 1a1fefc237 chore: create AuthApiService 2025-11-25 09:19:58 +07:00
ValdiANS 47690f82ac chore(FE-199): update Expense Request approval line step name 2025-11-25 09:18:04 +07:00
ValdiANS b868a37485 chore(FE-188,193): adjust ExpenseRequestKandangDetailExpense component 2025-11-25 09:17:02 +07:00
randy-ar 034d185b84 fix(FE): make text area debounce input for lag textarea input issue 2025-11-24 17:18:16 +07:00
ValdiANS e4a6b22357 chore(FE-188,193,199): adjust Expense Request Form and integrate to API 2025-11-24 09:54:28 +07:00
ValdiANS 82eac4a965 chore(FE-198): adjust Expense Request Form validation 2025-11-24 09:47:09 +07:00
ValdiANS 20c3e2d6b4 feat(FE-200,204): create ExpenseRealizationKandangDetailExpense component 2025-11-24 09:44:48 +07:00
ValdiANS f24ae992e6 feat(FE-206): create Expense Realization Form validation 2025-11-24 09:43:58 +07:00
ValdiANS 4f375a4f0b feat(FE-200,204): create Expense Realization Form 2025-11-24 09:43:20 +07:00
ValdiANS 510d10270e feat(FE-195): implement bulk approve/reject in Expense list page 2025-11-24 09:42:14 +07:00
ValdiANS b083b9cb1a feat(FE-196): create Expense Request Detail's content component 2025-11-24 09:38:31 +07:00
ValdiANS 93d14cb98b feat(FE-205): create Expense Realization Detail's content component 2025-11-24 09:38:13 +07:00
ValdiANS b0bd2bd8a5 chore(FE-196,205): refactor ExpenseDetail component 2025-11-24 09:35:30 +07:00
ValdiANS c0bba827a0 chore: remove dummy data 2025-11-24 09:31:27 +07:00
ValdiANS 032e9d45b3 feat: add logout functionality 2025-11-24 09:30:12 +07:00
ValdiANS 4027b25598 chore(FE-204): create Edit Expense Realization page 2025-11-24 09:26:37 +07:00
ValdiANS 70b1ba3f6b chore(FE-200): create Add Expense Realization page 2025-11-24 09:26:20 +07:00
ValdiANS d34c113be3 chore(FE-205): create layout file for expense realization detail page 2025-11-24 09:23:54 +07:00
ValdiANS 6a08854603 chore(FE-204,207): add validation to check if expense can be edited 2025-11-24 09:21:32 +07:00
rstubryan 824eed910a fix(resolve): fix resolve merge 2025-11-24 08:34:50 +07:00
randy-ar d769bfe452 fix(FE-270): adjust endpoint get next period project flock 2025-11-23 13:34:45 +07:00
rstubryan 274322606d refactor(FE-212): enhance quantity and price handling in PurchaseOrderStaffApprovalForm with improved validation and formatting 2025-11-22 13:47:24 +07:00
rstubryan 62c3d2af53 refactor(FE-212): add quantity field handling in PurchaseOrderStaffApprovalForm for improved item updates 2025-11-22 13:36:38 +07:00
rstubryan 01b9595606 refactor(FE-212): replace quantity input with NumberInput for editable rows in PurchaseOrderStaffApprovalForm 2025-11-22 13:32:10 +07:00
rstubryan 09065f59cf refactor(FE-212): enhance approval step logic in PurchaseOrderStaffApprovalForm for better item management 2025-11-22 13:23:23 +07:00
rstubryan ad79f29494 refactor(FE-212): update supplier data fetching logic to handle invalid supplier IDs in PurchaseRequestForm 2025-11-22 13:00:57 +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
rstubryan 5bb94b5679 refactor(FE-Issue): add validation to prevent selecting the same warehouse for source and destination in MovementForm 2025-11-22 11:33:28 +07:00
rstubryan 3c7f630580 feat(FE-Storyless): add date input for transfer date in MovementForm 2025-11-22 11:17:06 +07:00
rstubryan 6ce5a5b625 refactor(FE-208): filter supplier options by category in MovementForm for improved data relevance 2025-11-22 11:10:49 +07:00
rstubryan c12a58cb6d refactor(FE-208): add validation to ensure destination warehouse is different from source warehouse in MovementForm 2025-11-22 11:07:23 +07:00
rstubryan 34c1da94d8 refactor(FE-Issue): remove unused product and warehouse selection from MovementTable for cleaner UI 2025-11-22 10:47:27 +07:00
rstubryan 5b84a19afa refactor(FE-Issue): add max length validation for product category name in ProductCategoryForm 2025-11-22 10:35:56 +07:00
rstubryan a4a2b76277 fix(resolve): fix resolve MR 2025-11-22 10:25:37 +07:00
rstubryan 23abdbb78f refactor(FE-208): display formatted total and subtotal quantities in PurchaseOrderInvoice for improved clarity 2025-11-22 10:21:36 +07:00
rstubryan 6a39c2fd3f refactor(FE-208): swap accessor keys for total_qty and sub_qty in PurchaseOrderDetail for accurate data representation 2025-11-22 10:17:38 +07:00
rstubryan f9215738aa feat(FE-212): add quantity validation to PurchaseOrderAcceptApprovalForm to prevent submission with exceeded received quantities 2025-11-22 10:16:39 +07:00
rstubryan b12a1ebd36 feat(FE-212): add rawDataApprovals prop to PurchaseOrderStaffApprovalForm for improved approval data handling 2025-11-22 10:11:30 +07:00
rstubryan 0fefe5e035 feat(FE-212): enhance PurchaseOrderStaffApprovalForm to dynamically determine form type and validate product selection for duplicates 2025-11-22 09:47:20 +07:00
rstubryan ef95d1a0e8 refactor(FE-208): improve deletion logic in PurchaseOrderDetail to restrict item deletion to approval step 3 and enhance approval step handling 2025-11-22 00:58:06 +07:00
rstubryan 30ab69ae21 refactor(FE-208,212,213): update PurchaseOrderForm and PurchaseOrderStaffApprovalForm to allow optional product and warehouse fields and control visibility of action buttons based on approval step 2025-11-22 00:52:23 +07:00
rstubryan 5b28067203 feat(FE-208,212,213): enhance PurchaseOrderStaffApprovalForm with product and warehouse selection for new items 2025-11-21 22:46:50 +07:00
rstubryan ffea96edf9 refactor(FE-208): update deletion logic in PurchaseOrderDetail to allow item deletion only at approval step 3 2025-11-21 20:30:06 +07:00
rstubryan 1a74a9d33f feat(FE-208,212,213): integrate dynamic supplier selection in PurchaseOrderAcceptApprovalForm 2025-11-21 20:23:58 +07:00
rstubryan b9990e0253 refactor(FE-208): enhance PurchaseRequestForm to fetch and display supplier-specific products 2025-11-21 18:54:17 +07:00
rstubryan 95a7afdaa6 feat(FE-208): add refetchData prop to PurchaseOrderDetail and related forms for improved data synchronization 2025-11-21 18:18:40 +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
rstubryan c74733430b feat(FE-208): implement conditional item deletion in PurchaseOrderDetail and update form handling in PurchaseOrderStaffApprovalForm 2025-11-21 13:26:34 +07:00
rstubryan f6fe2d4eb1 refactor(FE-208): remove 'Jumlah Retur' column from PurchaseOrderDetail for UI simplification 2025-11-21 11:01:33 +07:00
rstubryan d624da97c3 feat(FE-208): replace product name input with select dropdown for better usability 2025-11-21 10:13:38 +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
rstubryan 63e5962a4e fix(resolve): fix resolve MR 2025-11-21 09:25:16 +07:00
rstubryan 835074f538 refactor(FE-212): group purchase items by warehouse in PurchaseOrderStaffApprovalForm 2025-11-21 09:17:00 +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
rstubryan e00b7bc3f2 refactor(FE-212): update default unit of measure in PurchaseOrderAcceptApprovalForm 2025-11-20 23:52:35 +07:00
rstubryan 51f157dfad refactor(FE-208,212): enhance quantity validation and formatting in PurchaseOrderAcceptApprovalForm 2025-11-20 23:50:14 +07:00
rstubryan c1d71ee3c6 refactor(FE-212): add warehouse information to PurchaseOrderAcceptApprovalForm 2025-11-20 23:31:35 +07:00
rstubryan c8b3e52ac0 refactor(FE-212): remove commented-out edit button in PurchaseTable component 2025-11-20 23:28:05 +07:00
rstubryan b2ef545f63 refactor(FE-208,212,213): replace product_id and warehouse_id with purchase_item_id in PurchaseRequestStaffApprovalForm and related schemas 2025-11-20 23:21:12 +07:00
rstubryan a6d187a8b3 refactor(FE-208,212,213): add action field to staff approval request payloads and implement rejection handling in PurchaseOrderDetail 2025-11-20 23:05:12 +07:00
rstubryan 24e2bcf35d refactor(FE-212): update notes field in UpdateStaffApprovalRequestPayload for consistency 2025-11-20 22:54:22 +07:00
rstubryan 417d08e0fc refactor(FE-212,213): unify purchase API service references in Purchase components 2025-11-20 22:39:04 +07:00
rstubryan 6e9d065bc6 refactor(FE-212): change HTTP method to DELETE for item removal in PurchaseRequestForm 2025-11-20 22:16:44 +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 4ddc44b59c refactor(FE-208,212): enhance product selection and quantity input handling in PurchaseRequestForm based on supplier selection 2025-11-20 15:38:04 +07:00
rstubryan e0b4805d0a refactor(FE-208,212): reset touched fields and values for purchase items in PurchaseRequestForm 2025-11-20 15:36:12 +07:00
rstubryan 074e6fad05 refactor(FE-208,212): improve credit term handling and validation in PurchaseRequestForm 2025-11-20 15:33:43 +07:00
rstubryan e640bce8ea refactor(FE-208): streamline supplier change handling and reset purchase items in PurchaseRequestForm 2025-11-20 15:25:39 +07:00
rstubryan f1e5692f8f refactor(FE-208,212): initialize items in PurchaseRequestForm with initial values 2025-11-20 14:50:36 +07:00
rstubryan 655e971788 refactor(FE-208): update input wrapper classes for consistency in PurchaseRequestForm 2025-11-20 14:44:41 +07:00
rstubryan 00e18d6d0d refactor(FE-208,212): enhance form validation and streamline change handlers in PurchaseRequestForm 2025-11-20 14:13:57 +07:00
rstubryan 343cc7c4e7 refactor(FE-212): remove unused poDate and supplier fields from table filter in PurchaseTable 2025-11-20 13:34:52 +07:00
rstubryan 4e8b17f55c refactor(FE-208): remove unused supplier state and handlers in PurchaseTable 2025-11-20 13:32:26 +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
rstubryan 432ea1e975 refactor(FE-208,212): update input wrapper classes for improved responsiveness in PurchaseRequestForm 2025-11-19 17:12:18 +07:00
rstubryan 14f216a352 refactor(FE-208,212): rename product_warehouse to warehouse and update related schema and form handling 2025-11-19 16:21:46 +07:00
rstubryan 8bda56e5d3 refactor(FE-208,212): add refreshApprovals and onModalClose props to approval forms 2025-11-19 13:27:36 +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
rstubryan 9d6455167f refactor(FE-208,212): enhance approval button layout and add reject option in PurchaseOrderDetail 2025-11-19 11:40:47 +07:00
rstubryan e3274a3353 refactor(FE-208,212): streamline approval button handling in PurchaseOrderDetail 2025-11-19 11:37:32 +07:00
rstubryan 07fd71558e refactor(FE-208,212): remove debug information from PurchaseOrderAcceptApprovalForm 2025-11-19 11:02:50 +07:00
rstubryan d2a69917e7 refactor(FE-208,212): improve rendering of grouped goods receipt items in PurchaseOrderDetail 2025-11-19 10:55:44 +07:00
rstubryan af5dfa9292 refactor(FE-208,212): implement grouping of goods receipt items and enhance table rendering in PurchaseOrderDetail 2025-11-19 10:50:53 +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 b520b4ee54 refactor(FE-208,212,213): add transport-related fields and update form handling in PurchaseOrder forms 2025-11-19 10:19:05 +07:00
rstubryan 89d9d40713 refactor(FE-208,212): remove warehouse-related fields and logic from PurchaseOrder forms and schema 2025-11-19 09:17:17 +07:00
rstubryan 17378d8408 refactor(FE-208,212): rename getPurchaseItemError to isRepeaterInputError for clarity in validation checks 2025-11-18 23:13:15 +07:00
rstubryan 25544e2e38 refactor(FE-208,212): enhance PurchaseOrderDetail and PurchaseOrderStaffApprovalForm to conditionally render invoice and item receipt based on approval steps 2025-11-18 23:10:49 +07:00
rstubryan 89b54f6f87 refactor(FE-208,212): update PurchaseOrderDetail to conditionally render invoice based on update permissions 2025-11-18 22:51:29 +07:00
rstubryan 3a431352ed refactor(FE-208,212): enhance PurchaseOrderDetail and PurchaseOrderStaffApprovalForm to conditionally allow updates based on approval step 2025-11-18 22:46:46 +07:00
rstubryan f6afb741af refactor(FE-208,212): enhance PurchaseOrderStaffApprovalForm for edit functionality and validation checks 2025-11-18 22:32:39 +07:00
rstubryan f4bb87550c refactor(FE-212): add updateStaffApproval method and enhance PurchaseStaffApprovalItem schema with optional purchase_item_id 2025-11-18 22:28:27 +07:00
rstubryan 3d468d9507 refactor(FE-208,213): simplify PurchaseOrderForm and PurchaseOrderStaffApprovalForm by removing unused product and warehouse fields and enhancing price validation 2025-11-18 22:13:14 +07:00
rstubryan 0c79e86736 refactor(FE-208,212): update PurchaseOrderForm and PurchaseOrderStaffApprovalForm for improved validation and dynamic item handling 2025-11-18 21:00:46 +07:00
rstubryan 1b90d657ff refactor(FE-208,212): update PurchaseOrderForm and PurchaseOrderStaffApprovalForm for improved validation and dynamic item handling 2025-11-18 20:43:57 +07:00
rstubryan 0d025ba34c refactor(FE-208,213): streamline PurchaseOrderAcceptApprovalForm by removing hardcoded warehouse options and utilizing initialValues for dynamic data 2025-11-18 18:31:49 +07:00
rstubryan 8c3cd3bc53 refactor(FE-208,213): enhance PurchaseOrderDetail and PurchaseOrderStaffApprovalForm components with initialValues prop and clean up unused code 2025-11-18 18:22:10 +07:00
rstubryan 75e7b9a6de refactor(FE-208,213): update PurchaseOrderDetail and PurchaseOrderInvoice components for improved warehouse and supplier details 2025-11-18 14:58:49 +07:00
rstubryan 00c432a918 refactor(FE-208,212,213): update approval steps and improve PurchaseOrderDetail component structure 2025-11-18 14:49:30 +07:00
rstubryan f6cf22f885 refactor(FE-208): update PurchaseOrderDetail component to use initialValues prop 2025-11-18 14:36:16 +07:00
rstubryan 3d86c9ce6b refactor(FE-Storyless): add support for 'UPDATED' action in approval status and make page parameter optional 2025-11-18 14:34:26 +07:00
rstubryan d93f0c26b6 refactor(FE-212): update PurchaseOrderDetail component to use initialValues prop instead of data 2025-11-18 14:32:24 +07:00
rstubryan e8dd4f3759 feat(FE-218,212,213): implement PurchaseOrderDetail component and update related types 2025-11-18 14:30:09 +07:00
rstubryan edd59598f9 refactor(FE-208,212): refine PurchaseRequestForm validation and state management 2025-11-18 13:50:54 +07:00
rstubryan 964a4500ab feat(FE-208): update date formatting in PurchaseTable for improved readability 2025-11-18 13:39:42 +07:00
rstubryan 9a650a130d feat(FE-208): enhance PurchaseTable with improved state management and memoization 2025-11-18 13:35:58 +07:00
rstubryan 2f2c1fca07 feat(FE-212): add sales orders link and accepted file types constants 2025-11-18 13:25:21 +07:00
rstubryan 2680d5a24d feat(FE-212): add sales orders link and accepted file types constants 2025-11-18 13:24:34 +07:00
rstubryan 0f9019e7b4 fix(resolve): fix resolve MR 2025-11-18 11:36:41 +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
randy-ar a9bdb6c36e feat(FE-177): Integrate API sales order and fixing sales order initial state 2025-11-17 15:59:31 +07:00
rstubryan 9260f1aff6 refactor(FE-212): update qty validation in PurchaseRequestForm schema to enforce numeric input 2025-11-17 14:54:15 +07:00
rstubryan 0087ba384c refactor(FE-212): update qty validation in PurchaseRequestForm schema to enforce numeric input 2025-11-17 14:45:46 +07:00
rstubryan 71a41d3f37 feat(FE-208,212): update purchase request form to use 'qty' instead of 'quantity' and add credit term field 2025-11-17 14:38:09 +07:00
rstubryan 6467af35bc refactor(FE-208): restructure goods receipt table columns in PurchaseOrderDetail 2025-11-17 13:51:32 +07:00
rstubryan c8f1ea0e4f feat(FE-208): add index column to goods receipt table in PurchaseOrderDetail 2025-11-17 13:45:28 +07:00
rstubryan 283c2b2a44 feat(FE-208,212): implement bulk delete functionality for purchase order items with confirmation modal 2025-11-17 13:21:43 +07:00
rstubryan 7ec4105454 refactor(FE-208,212): implement delete functionality for purchase order items with confirmation modal 2025-11-17 13:09:47 +07:00
rstubryan d0ba9eadbd refactor(FE-208): add edit functionality and modal for Penerimaan Barang in PurchaseOrderDetail 2025-11-17 11:29:17 +07:00
rstubryan 2190f65cb2 refactor(FE-208): add edit functionality to Item Pembelian section in PurchaseOrderDetail 2025-11-17 11:15:13 +07:00
rstubryan b82ba60a32 refactor(FE-208): enhance PurchaseOrderDetail layout with section header for goods receipt information 2025-11-17 10:21:17 +07:00
rstubryan 30ed70b669 refactor(FE-212): add DeletePurchaseRequestItemPayload and implement PurchaseDeleteItemsService 2025-11-17 10:13:30 +07:00
rstubryan 69a8899cac refactor(FE-212): simplify PurchaseRequestService to a constant API instance 2025-11-17 09:39:16 +07:00
rstubryan 9f41768e54 refactor(FE-208): rename PurchaseRequisitions to PurchaseRequest and update related API references 2025-11-17 09:22:49 +07:00
rstubryan c951f09667 refactor(FE-208): rename PurchaseRequisitions to PurchaseRequest and update related API references 2025-11-17 09:21:18 +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
rstubryan 64605b168e refactor(FE-208): remove period from currency formatting in invoice display 2025-11-15 10:57:30 +07:00
rstubryan e421c7d422 refactor(FE-208): enhance PurchaseOrderInvoice layout with improved cell styling and add inner table header 2025-11-15 10:54:59 +07:00
rstubryan 4bd6ac3cac refactor(FE-208): enhance PurchaseOrderInvoice styles with primary color and adjust address maxWidth 2025-11-15 10:43:46 +07:00
rstubryan e638856ea9 chore(deps): add @react-pdf/renderer dependency for PDF generation functionality 2025-11-15 10:36:30 +07:00
rstubryan c45c8601cb feat(FE-208): add PurchaseOrderInvoice component for PDF generation and update PurchaseOrderDetail to integrate invoice display 2025-11-15 10:36:01 +07:00
rstubryan 57a867f611 refactor(FE-208): update approval buttons in PurchaseOrderDetail to include Manager Approval 2025-11-14 16:08:30 +07:00
rstubryan a5758aece4 refactor(FE-208): remove unnecessary font styling from purchase number display in PurchaseOrderDetail 2025-11-14 16:07:21 +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
rstubryan 0c4c0ce3ab feat(FE-208,212): add PO document path and update PurchaseOrderDetail to display document link 2025-11-14 13:22:05 +07:00
rstubryan 00e0202be2 feat(FE-208): add confirmation modal for manager approval with notes functionality 2025-11-14 10:52:36 +07:00
rstubryan 3d49947c1e feat(FE-212): add manager approval requisition type and service with nullable notes 2025-11-14 10:52:21 +07:00
rstubryan 1ab72b8637 feat(FE-208): add staff and accept approval modals with action buttons in PurchaseOrderDetail 2025-11-14 10:23:37 +07:00
rstubryan f98a597115 refactor(FE-208): refactor PurchaseOrderDetail to integrate Purchase data and update Goods Receipt structure 2025-11-14 10:11:13 +07:00
rstubryan 1be61ae4ff feat(FE-212): extend PurchaseItem type with additional fields for enhanced purchase details 2025-11-14 09:09:23 +07:00
rstubryan 1b64c1f5d1 feat(FE-208): add goods receipt section with table and dummy data in purchase order detail view 2025-11-14 09:05:58 +07:00
rstubryan 7485919e52 feat(FE-208): implement purchase order detail view with collapsible sections and summary table 2025-11-13 16:43:08 +07:00
rstubryan e5d9612e29 refactor(FE-208): update 'Purchase' title to 'Pembelian' in constants 2025-11-13 15:50:11 +07:00
rstubryan b6ac8026c7 chore: prettier format 2025-11-13 15:43:41 +07:00
rstubryan d9ebde65cb chore: prettier format 2025-11-13 15:35:15 +07:00
rstubryan 5648b51c2e feat(FE-Storyless): add collapsible functionality and improve image handling 2025-11-13 14:54:37 +07: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
rstubryan c2479ad248 chore: prettier format 2025-11-13 14:08:35 +07:00
rstubryan b3f4e42f1a chore: prettier format 2025-11-13 14:01:07 +07:00
rstubryan ac8c39324b fix(resolve): resolve MR 2025-11-13 13:06:18 +07:00
rstubryan 26811f5e3e Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-161/TASK-208-212-slicing-ui-and-validation-create-purchase-request-form 2025-11-13 10:24:37 +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
rstubryan 4a974048a7 feat(FE-208): refactor Purchase Order forms to use table layout for improved readability and maintainability 2025-11-12 21:52:02 +07:00
rstubryan cd8ab8844b feat(FE-208): enhance Purchase Order acceptance form with Card component and improved layout for received date inputs 2025-11-12 20:43:34 +07:00
rstubryan 5d88af1a31 feat(FE-208,212): implement table-based UI for Purchase Order acceptance and staff approval forms 2025-11-12 20:27:06 +07:00
rstubryan 4215c6c6ce feat(FE-208): enhance DateInput component with range selection and modal support 2025-11-12 16:54:17 +07:00
rstubryan f264474293 refactor(FE-208): update modal width classes in PurchaseTable for improved responsiveness 2025-11-12 13:14:27 +07:00
rstubryan c770651a01 refactor(FE-212): simplify validation for supplier, area, location, and warehouse fields in PurchaseRequisitionsForm 2025-11-12 12:58:08 +07:00
rstubryan 603f95a9b2 refactor(FE-208,212): update terminology in PurchaseOrder forms for consistency and clarity 2025-11-12 11:54:00 +07:00
rstubryan f26e54e8f2 refactor(FE-208,212): update width classes for form fields in PurchaseOrder forms for better responsiveness 2025-11-12 11:16:46 +07:00
rstubryan bc53b9073c feat(FE-208,212): simplify PurchaseOrder forms by removing unused fields and updating user names 2025-11-12 10:51:57 +07:00
rstubryan 45f12cad4f feat(FE-Storyless): update modal functionality to use show() method and clean up code 2025-11-12 09:33:32 +07:00
rstubryan fde9c449a6 feat(FE-Storyless): update UI form UI repo 2025-11-12 08:57:06 +07:00
rstubryan ecb497430a feat(FE-208,212): enhance PurchaseOrder forms with onCancel functionality and UI improvements 2025-11-11 15:05:05 +07:00
rstubryan 8c17367fb6 feat(FE-208,212): add PurchaseOrderAcceptApprovalForm and validation schema for acceptance of purchase requisitions 2025-11-11 14:06:53 +07:00
rstubryan 21ac73527d feat(FE-212): refactor purchase requisitions services and update item schema for approval process 2025-11-11 13:23:48 +07:00
rstubryan f7b2e3c6f2 feat(FE-208,212): add PurchaseRequisitionsStaffApprovalForm and schema for staff approval process 2025-11-11 11:26:46 +07:00
rstubryan 5fc01a9afa feat(FE-208): use CheckboxInput component for item selection in PurchaseRequisitionsForm 2025-11-11 11:26:22 +07:00
rstubryan 3ed3e2e21a feat(FE-208): add PurchaseOrderDetail component for displaying approval steps 2025-11-10 17:09:28 +07:00
rstubryan 7d1992d075 feat(FE-212): add staff approval service and payload types for purchase requisitions 2025-11-10 17:09:04 +07:00
rstubryan 63dac00f17 feat(FE-212): add PURCHASE_ORDER_APPROVAL_LINE for purchase order approval steps 2025-11-10 14:33:20 +07:00
rstubryan efcc14f3ab refactor(FE-212): rename PurchaseApi to PurchaseRequisitionsApi and update related references in forms and tables 2025-11-10 14:33:01 +07:00
rstubryan 5e64d37c61 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into feat/FE/US-161/TASK-208-212-slicing-ui-and-validation-create-purchase-request-form 2025-11-10 13:12:02 +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
rstubryan 8db9d1a52c refactor(FE-212): update product warehouse fetching logic and options mapping in PurchaseRequisitionsForm 2025-11-08 10:53:29 +07:00
rstubryan 10dca5c692 refactor(FE-208): rename PurchaseRequestForm to PurchaseRequisitionsForm and update related components 2025-11-08 09:53:21 +07:00
rstubryan 53751d566c refactor(FE-212): rename PurchaseRequestForm schema to PurchaseRequisitionsForm and update related types 2025-11-08 09:52:48 +07:00
rstubryan 12a1e61b68 refactor(FE-212): update purchase types and payloads for requisition handling 2025-11-08 09:52:34 +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
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
rstubryan ffa11fa20a feat(FE-170): add grading button for laying category in RecordingTable 2025-11-06 15:46:21 +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
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
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
rstubryan 8c29358594 refactor(FE-208): remove required attribute from Area and Lokasi SelectInputs in PurchaseRequestForm 2025-11-04 14:36:07 +07:00
rstubryan 9d86e21657 refactor(FE-208): remove checkbox selection from PurchaseTable component 2025-11-04 13:20:44 +07:00
rstubryan ef193b9f03 feat(FE-208,212): enhance PurchaseRequestForm with dummy data and update type definitions for purchase items 2025-11-04 13:17:43 +07:00
rstubryan 4828af71b8 feat(FE-208): create PurchaseEdit page with dummy data and integrate PurchaseRequestForm 2025-11-04 11:31:32 +07:00
rstubryan 3312a47f38 feat(FE-208): create PurchaseDetail page with dummy data and integrate PurchaseRequestForm 2025-11-04 11:22:52 +07:00
rstubryan c790180e86 feat(FE-208): add Layout component to wrap children with SuspenseHelper 2025-11-04 10:51:54 +07:00
rstubryan ef339e128d feat(FE-208,212): add Purchase and PurchaseTable components for managing purchase requests 2025-11-04 10:51:12 +07:00
rstubryan 7c9c7eac10 refactor(FE-208,212): update import path for PurchaseRequestForm to reflect new directory structure 2025-11-04 10:49:45 +07:00
rstubryan 986830aa47 refactor(FE-208,212): rename PurchaseRequestForm files and update import paths for consistency 2025-11-04 10:49:19 +07:00
rstubryan 1e44fec15f feat(FE-208,212): implement PurchaseService for fetching purchase request data with dummy response 2025-11-04 10:48:57 +07:00
rstubryan 39dbf57d7f refactor(FE-208,212): streamline PurchaseRequestForm structure, enhance state management, and improve field handling for purchase items 2025-11-04 09:20:39 +07:00
rstubryan 289c8d5672 refactor(FE-208,212): enhance PurchaseRequestForm with product data fetching, update price and UOM fields based on selected products 2025-11-04 08:56:57 +07:00
rstubryan ee24ceaff1 refactor(FE-208,212): update PurchaseRequestForm to reset location fields on area change, improve handling of location value and dependencies 2025-11-03 15:45:42 +07:00
rstubryan ecdd8ae49c refactor(FE-208,212): update PurchaseRequestForm schema and validation, improve credit term handling and reset logic for supplier changes 2025-11-03 15:19:19 +07:00
rstubryan e1d070b3af refactor(FE-208,212): update PurchaseRequestForm schema and validation, enhance credit term handling and improve error messages for required fields 2025-11-03 15:02:00 +07:00
rstubryan 4149c51a7b refactor(FE-208,212): update PurchaseRequestForm validation for area and location fields, enhance error handling and conditional checks 2025-11-03 14:29:24 +07:00
rstubryan ae5a57277b refactor(FE-208,212): enhance PurchaseRequestForm with location and warehouse handling, update validation and reset logic for dependent fields 2025-11-03 14:06:32 +07:00
rstubryan 7b19cd4a21 refactor(FE-208,212): enhance PurchaseRequestForm with area and location fields, update validation and data handling for new inputs 2025-11-03 13:01:17 +07:00
rstubryan 408250d7ed refactor(FE-208): enhance PurchaseRequestForm layout and actions, replace header with custom component and improve button handling 2025-11-03 11:39:37 +07:00
rstubryan ae91e17ac0 refactor(FE-207,212): update PurchaseRequestForm schema and validation, enforce required fields and improve data handling for purchase items 2025-11-03 11:29:43 +07:00
rstubryan b4a9c86c2a refactor(FE-212): update PurchaseRequestForm schema and validation, streamline warehouse handling and add sub quantity field 2025-11-03 11:04:07 +07:00
rstubryan 1d79e8de1d refactor(FE-Storyless): streamline RecordingForm component by native card and optimizing layout for better usability 2025-11-03 10:45:36 +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
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
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
rstubryan d53f7fc72f fix(resolve): fix resolve merge 2025-11-03 10:12:12 +07:00
rstubryan 4e4117b5b0 fix(resolve): fix resolve merge 2025-11-03 10:02:26 +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
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
rstubryan 3a52d800e0 feat(FE-174): add grading functionality to daily recording form with validation 2025-10-31 14:01:51 +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
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 a5f8eb60c6 fix(FE-Storyless): update SelectInput value handling to use undefined instead of null for better compatibility 2025-10-30 13:32:32 +07:00
rstubryan c83ebd73be refactor(FE-Storyless): remove unnecessary padding from badge styles for improved layout 2025-10-30 13:08:36 +07:00
rstubryan be68353b38 feat(FE-114): add unique keys to SelectInput components in RecordingForm for improved rendering 2025-10-30 12:50:56 +07:00
rstubryan 2307035717 feat(FE-Storyless): add custom control component to SelectInput for adornment support 2025-10-30 12:00:22 +07:00
rstubryan a8fee20133 refactor(FE-208,212): enhance PurchaseRequestForm with product and product warehouse fields 2025-10-30 10:39:23 +07:00
rstubryan b0e8a460fd refactor(FE-208,212): update PurchaseRequestForm schema and component to handle warehouse IDs 2025-10-30 09:29:38 +07:00
rstubryan b2c38cd06f feat(FE-208): create PurchaseRequestForm component and add AddPurchaseRequest page 2025-10-29 21:12:42 +07:00
rstubryan 7ba7b884a4 feat(FE-212): rename purchasing files and update validation schemas for purchase requests 2025-10-29 21:12:24 +07:00
rstubryan 3daf1a518e feat(FE-208): add 'Purchase' link to navigation constants 2025-10-29 21:11:40 +07:00
rstubryan c6fcb17b4d feat(FE-212): add validation schemas for purchase request and update forms 2025-10-29 20:31:31 +07:00
rstubryan 8b09a8d315 feat(FE-212): implement PurchaseApi service for purchase requests 2025-10-29 17:26:26 +07:00
rstubryan 215580215e feat(FE-212): add types for purchase creation and updates 2025-10-29 17:26:06 +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
144 changed files with 22564 additions and 8043 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
+21 -3
View File
@@ -15,8 +15,24 @@ stages:
script: script:
- echo "Installing dependencies..." - echo "Installing dependencies..."
- npm ci --no-audit --no-fund - npm ci --no-audit --no-fund
- echo "Build env used:"
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
- echo "Building Next.js static export..." - echo "Building Next.js static export..."
- npx next build - npx next build
- |
mkdir -p out
cat <<EOF > out/build-info.json
{
"commit": "$CI_COMMIT_SHORT_SHA",
"pipeline": "$CI_PIPELINE_ID",
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
}
EOF
artifacts: artifacts:
name: 'out-$CI_COMMIT_SHORT_SHA' name: 'out-$CI_COMMIT_SHORT_SHA'
paths: paths:
@@ -106,8 +122,11 @@ build:dev:
environment: environment:
name: development name: development
variables: variables:
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id' # NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id' # NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
deploy:dev: deploy:dev:
<<: *deploy_template <<: *deploy_template
@@ -142,5 +161,4 @@ deploy:dev:
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd" # CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
# environment: # environment:
# name: production # name: production
# url: https://royalgoldcapital.com
+571 -71
View File
@@ -8,14 +8,14 @@
"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.7",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
@@ -33,13 +33,12 @@
"@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",
"daisyui": "^5.1.12", "daisyui": "^5.5.5",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "^15.5.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
@@ -1083,15 +1082,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.7.tgz",
"integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==", "integrity": "sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1099,9 +1098,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
"integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1115,9 +1114,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
"integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1131,9 +1130,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
"integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1147,9 +1146,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
"integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1163,9 +1162,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
"integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1179,9 +1178,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
"integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1195,9 +1194,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
"integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1211,9 +1210,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
"integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1274,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",
@@ -1647,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",
@@ -2269,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",
@@ -2603,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",
@@ -2627,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",
@@ -2728,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",
@@ -2754,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",
@@ -2813,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",
@@ -2820,9 +3063,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "5.3.10", "version": "5.5.5",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.10.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.5.tgz",
"integrity": "sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==", "integrity": "sha512-ekvI93ZkWIJoCOtDl0D2QMxnWvTejk9V5nWBqRv+7t0xjiBXqAK5U6o6JE2RPvlIC3EqwNyUoIZSdHX9MZK3nw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -3003,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",
@@ -3047,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",
@@ -3316,13 +3571,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.7.tgz",
"integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==", "integrity": "sha512-nU/TRGHHeG81NeLW5DeQT5t6BDUqbpsNQTvef1ld/tqHT+/zTx60/TIhKnmPISTTe++DVo+DLxDmk4rnwHaZVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "15.5.3", "@next/eslint-plugin-next": "15.5.7",
"@rushstack/eslint-patch": "^1.10.3", "@rushstack/eslint-patch": "^1.10.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
@@ -3680,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": {
@@ -3843,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",
@@ -4196,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",
@@ -4212,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",
@@ -4248,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",
@@ -4630,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",
@@ -4708,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",
@@ -4725,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": {
@@ -5110,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",
@@ -5182,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",
@@ -5313,12 +5654,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.5.3", "version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.5.3", "@next/env": "15.5.7",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -5331,14 +5672,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.3", "@next/swc-darwin-arm64": "15.5.7",
"@next/swc-darwin-x64": "15.5.3", "@next/swc-darwin-x64": "15.5.7",
"@next/swc-linux-arm64-gnu": "15.5.3", "@next/swc-linux-arm64-gnu": "15.5.7",
"@next/swc-linux-arm64-musl": "15.5.3", "@next/swc-linux-arm64-musl": "15.5.7",
"@next/swc-linux-x64-gnu": "15.5.3", "@next/swc-linux-x64-gnu": "15.5.7",
"@next/swc-linux-x64-musl": "15.5.3", "@next/swc-linux-x64-musl": "15.5.7",
"@next/swc-win32-arm64-msvc": "15.5.3", "@next/swc-win32-arm64-msvc": "15.5.7",
"@next/swc-win32-x64-msvc": "15.5.3", "@next/swc-win32-x64-msvc": "15.5.7",
"sharp": "^0.34.3" "sharp": "^0.34.3"
}, },
"peerDependencies": { "peerDependencies": {
@@ -5392,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",
@@ -5582,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",
@@ -5612,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",
@@ -5705,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",
@@ -5764,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",
@@ -5970,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",
@@ -6009,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",
@@ -6064,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",
@@ -6309,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",
@@ -6348,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",
@@ -6538,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",
@@ -6588,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",
@@ -6836,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",
@@ -6916,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",
@@ -7053,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",
+4 -5
View File
@@ -11,14 +11,14 @@
"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.7",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
@@ -36,13 +36,12 @@
"@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",
"daisyui": "^5.1.12", "daisyui": "^5.5.5",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "^15.5.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^4", "tailwindcss": "^4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+6 -4
View File
@@ -34,13 +34,15 @@ const ExpenseEditPage = () => {
return; return;
} }
const isExpenseRejectedOrApproved = const isExpenseCanBeEdited =
!isLoadingExpense && !isLoadingExpense &&
isResponseSuccess(expense) && isResponseSuccess(expense) &&
(expense.data.approval.action === 'REJECTED' || expense.data.latest_approval.step_number !== 5 &&
expense.data.approval.step_number === 5); (expense.data.latest_approval.step_number === 1 ||
expense.data.latest_approval.step_number === 2 ||
expense.data.latest_approval.step_number === 3);
if (isExpenseRejectedOrApproved) { if (!isLoadingExpense && !isExpenseCanBeEdited) {
router.back(); router.back();
return; return;
} }
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ExpenseRealizationForm from '@/components/pages/expense/form/ExpenseRealizationForm';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ExpenseRealizationEditPage = () => {
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 isExpenseRealizationCanBeEdited =
!isLoadingExpense &&
isResponseSuccess(expense) &&
expense.data.latest_approval.action !== 'REJECTED' &&
(expense.data.latest_approval.step_number === 4 ||
expense.data.latest_approval.step_number === 5);
if (!isLoadingExpense && !isExpenseRealizationCanBeEdited) {
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) && (
<ExpenseRealizationForm type='edit' initialValues={expense.data} />
)}
</div>
);
};
export default ExpenseRealizationEditPage;
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ExpenseRealizationForm from '@/components/pages/expense/form/ExpenseRealizationForm';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ExpenseRealization = () => {
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 isExpenseCanBeRealized =
isResponseSuccess(expense) &&
expense.data.latest_approval.action !== 'REJECTED' &&
expense.data.latest_approval.step_number === 3;
if (isResponseSuccess(expense) && !isExpenseCanBeRealized) {
if (typeof window !== 'undefined') {
router.back();
}
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingExpense && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingExpense && isResponseSuccess(expense) && (
<ExpenseRealizationForm initialValues={expense.data} />
)}
</div>
);
};
export default ExpenseRealization;
@@ -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;
@@ -1,9 +1,9 @@
import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
const AddSalesOrder = () => { const AddSalesOrder = () => {
return ( return (
<div className='size-full p-4'> <div className='size-full p-4'>
<SalesForm /> <MarketingForm formType='add' />
</div> </div>
); );
}; };
@@ -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;
@@ -1,20 +1,22 @@
'use client'; 'use client';
import SalesOrderDetail from '@/components/pages/marketing/sales-orders/detail/SalesOrderDetail'; import MarketingDetail from '@/components/pages/marketing/detail/MarketingDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing'; import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
const DetailSalesOrder = () => { const DetailMarketing = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const soId = searchParams.get('salesOrderId'); const soId = searchParams.get('marketingId');
const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => const {
MarketingApi.getSingle(id) data: marketing,
); isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(soId, (id: number) => MarketingApi.getSingle(id));
if (!soId) { if (!soId) {
router.back(); router.back();
@@ -35,10 +37,13 @@ const DetailSalesOrder = () => {
<div className='w-full p-4'> <div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />} {isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && ( {!isLoading && isResponseSuccess(marketing) && (
<SalesOrderDetail initialValues={marketing.data} /> <MarketingDetail
initialValues={marketing.data}
refresh={refreshMarketing}
/>
)} )}
</div> </div>
); );
}; };
export default DetailSalesOrder; 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;
@@ -1,6 +1,6 @@
'use client'; 'use client';
import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing'; import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
@@ -10,10 +10,14 @@ const EditSalesOrder = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const soId = searchParams.get('salesOrderId'); const soId = searchParams.get('marketingId');
const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => const {
MarketingApi.getSingle(id) data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
); );
if (!soId) { if (!soId) {
@@ -34,7 +38,13 @@ const EditSalesOrder = () => {
<div className='w-full p-4'> <div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />} {isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && ( {!isLoading && isResponseSuccess(marketing) && (
<SalesForm formType='edit' initialValues={marketing.data} /> <MarketingForm
formType='edit'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)} )}
</div> </div>
); );
+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;
-10
View File
@@ -1,10 +0,0 @@
import SalesOrderTable from '@/components/pages/marketing/sales-orders/SalesOrderTable';
const SalesOrder = () => {
return (
<div className='w-full p-4'>
<SalesOrderTable />
</div>
);
};
export default SalesOrder;
@@ -11,10 +11,6 @@ const AddChickin = () => {
return ( return (
<> <>
<section className='w-full p-4'> <section className='w-full p-4'>
<FormHeader
title='Daftar Kandang Project Flock'
backUrl='/production/project-flock'
/>
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} /> <ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
</section> </section>
</> </>
@@ -1,343 +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/chickin';
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>
</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,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;
+11
View File
@@ -0,0 +1,11 @@
import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm';
const AddPurchaseRequest = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<PurchaseRequestForm />
</div>
);
};
export default AddPurchaseRequest;
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm';
import { PurchaseApi } from '@/services/api/purchase';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
const PurchaseEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const purchaseId = searchParams.get('purchaseId');
const { data: purchase, isLoading: isLoadingPurchase } = useSWR(
purchaseId,
(id: number) => PurchaseApi.getSingle(id)
);
if (!purchaseId) {
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 (!isLoadingPurchase && (!purchase || isResponseError(purchase))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingPurchase && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingPurchase && isResponseSuccess(purchase) && (
<PurchaseRequestForm type='edit' initialValues={purchase.data} />
)}
</div>
);
};
export default PurchaseEdit;
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import PurchaseOrderDetail from '@/components/pages/purchase/order/PurchaseOrderDetail';
import { PurchaseApi } from '@/services/api/purchase';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
const PurchaseDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const purchaseId = searchParams.get('purchaseId');
const {
data: purchase,
isLoading: isLoadingPurchase,
mutate: mutatePurchase,
} = useSWR(purchaseId, (id: number) => PurchaseApi.getSingle(id));
if (!purchaseId) {
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 (!isLoadingPurchase && (!purchase || isResponseError(purchase))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoadingPurchase && (
<div className='w-full flex flex-row justify-center items-center'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoadingPurchase && isResponseSuccess(purchase) && (
<PurchaseOrderDetail
type='detail'
initialValues={purchase.data}
refetchData={mutatePurchase}
/>
)}
</div>
);
};
export default PurchaseDetail;
+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;
+11
View File
@@ -0,0 +1,11 @@
import PurchaseTable from '@/components/pages/purchase/PurchaseTable';
const Purchase = () => {
return (
<section className='w-full p-4'>
<PurchaseTable />
</section>
);
};
export default Purchase;
+121 -26
View File
@@ -1,8 +1,11 @@
'use client'; 'use client';
import { HTMLAttributes, ReactNode } from 'react'; import { HTMLAttributes, ReactNode, useState } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import Image from 'next/image';
import Collapse from './Collapse';
import { Icon } from '@iconify/react';
export interface CardProps export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> { extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
@@ -10,8 +13,13 @@ export interface CardProps
subtitle?: string; subtitle?: string;
image?: string; image?: string;
imageAlt?: string; imageAlt?: string;
imageWidth?: number;
imageHeight?: number;
actions?: ReactNode; actions?: ReactNode;
footer?: ReactNode; footer?: ReactNode;
collapsible?: boolean;
defaultCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
className?: { className?: {
wrapper?: string; wrapper?: string;
image?: string; image?: string;
@@ -20,6 +28,7 @@ export interface CardProps
subtitle?: string; subtitle?: string;
actions?: string; actions?: string;
footer?: string; footer?: string;
collapsible?: string;
}; };
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full'; variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
@@ -30,14 +39,27 @@ const Card = ({
subtitle, subtitle,
image, image,
imageAlt, imageAlt,
imageWidth,
imageHeight,
actions, actions,
footer, footer,
collapsible,
defaultCollapsed = false,
onCollapsedChange,
className, className,
variant = 'default', variant = 'default',
size = 'md', size = 'md',
children, children,
...props ...props
}: CardProps) => { }: CardProps) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const handleCollapsedChange = (open: boolean) => {
const collapsed = !open;
setIsCollapsed(collapsed);
onCollapsedChange?.(collapsed);
};
const getCardClasses = () => { const getCardClasses = () => {
const baseClasses = 'card bg-base-100'; const baseClasses = 'card bg-base-100';
@@ -63,11 +85,31 @@ const Card = ({
); );
}; };
const getImageDimensions = () => {
if (variant === 'image-full') {
return {
width: imageWidth || 128,
height: imageHeight || 128,
};
}
const cardWidths = {
sm: 256, // w-64
md: 384, // w-96
lg: 448, // w-[28rem]
};
return {
width: imageWidth || cardWidths[size],
height: imageHeight || 192,
};
};
const getImageClasses = () => { const getImageClasses = () => {
if (variant === 'image-full') { if (variant === 'image-full') {
return cn('w-32 h-32 object-cover', className?.image); return cn('object-cover', className?.image);
} }
return cn('h-48 object-cover', className?.image); return cn('w-full object-cover', className?.image);
}; };
const getBodyClasses = () => { const getBodyClasses = () => {
@@ -102,45 +144,98 @@ const Card = ({
return cn('border-t border-base-300 mt-4 pt-4', className?.footer); return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
}; };
if (variant === 'image-full' && image) { const renderCardContent = () => {
const hasContent = children || actions || footer;
const titleContent = (
<div className='group flex items-center !justify-between w-full'>
<div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
</div>
{collapsible && (
<button
onClick={() => handleCollapsedChange(!isCollapsed)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
>
<Icon
icon={
isCollapsed
? 'material-symbols:expand-more'
: 'material-symbols:expand-less'
}
width={20}
/>
</button>
)}
</div>
);
const cardContent = (
<div className='space-y-4'>
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
return ( return (
<div className={getCardClasses()} {...props}> <>
{image && (
<figure> <figure>
<img <Image
src={image} src={image}
alt={imageAlt || title || 'Card image'} alt={imageAlt || title || 'Card image'}
width={getImageDimensions().width}
height={getImageDimensions().height}
className={getImageClasses()} className={getImageClasses()}
/> />
</figure> </figure>
)}
<div className={getBodyClasses()}> <div className={getBodyClasses()}>
{collapsible && hasContent ? (
<Collapse
variant='default'
bordered={false}
open={!isCollapsed}
onOpenChange={handleCollapsedChange}
title={titleContent}
titleClassName='w-full cursor-pointer'
contentClassName='p-0'
fullWidth={true}
>
{cardContent}
</Collapse>
) : (
<>
{(title || subtitle) && (
<div className='mb-4'>
{title && <h2 className={getTitleClasses()}>{title}</h2>} {title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>} {subtitle && (
{children} <p className={getSubtitleClasses()}>{subtitle}</p>
{actions && <div className={getActionsClasses()}>{actions}</div>} )}
</div> </div>
{footer && <div className={getFooterClasses()}>{footer}</div>} )}
{hasContent && cardContent}
</>
)}
</div>
</>
);
};
if (variant === 'image-full' && image) {
return (
<div className={getCardClasses()} {...props}>
{renderCardContent()}
</div> </div>
); );
} }
return ( return (
<div className={getCardClasses()} {...props}> <div className={getCardClasses()} {...props}>
{image && ( {renderCardContent()}
<figure>
<img
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div> </div>
); );
}; };
+6 -2
View File
@@ -26,6 +26,9 @@ export type CollapseProps = {
disabled?: boolean; disabled?: boolean;
/** Allow only one open at a time by switching to radio input */ /** Allow only one open at a time by switching to radio input */
asRadio?: boolean; asRadio?: boolean;
/** Force full width instead of auto-fit when collapsed
* (Khusus justify-between dan justify-end) */
fullWidth?: boolean;
/** Extra classnames */ /** Extra classnames */
className?: string; className?: string;
titleClassName?: string; titleClassName?: string;
@@ -44,6 +47,7 @@ export const Collapse = ({
bordered, bordered,
disabled, disabled,
asRadio = false, asRadio = false,
fullWidth,
className, className,
titleClassName, titleClassName,
contentClassName, contentClassName,
@@ -68,9 +72,9 @@ export const Collapse = ({
'collapse', 'collapse',
variant === 'arrow' && 'collapse-arrow', variant === 'arrow' && 'collapse-arrow',
variant === 'plus' && 'collapse-plus', variant === 'plus' && 'collapse-plus',
bordered && 'border base-content/20 border-opacity-20 rounded', bordered && 'border base-content/20 border-opacity-20 rounded-box',
disabled && 'opacity-60 pointer-events-none', disabled && 'opacity-60 pointer-events-none',
!open && 'w-fit', !fullWidth && !open && 'w-fit',
className className
); );
+7 -147
View File
@@ -1,161 +1,21 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Drawer from '@/components/Drawer'; import Drawer from '@/components/Drawer';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import Collapse from '@/components/Collapse';
import Button from '@/components/Button'; import Button from '@/components/Button';
import SidebarMenu from '@/components/molecules/SidebarMenu';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import { MAIN_DRAWER_LINKS } from '@/config/constant'; import { MAIN_DRAWER_LINKS } from '@/config/constant';
import { cn } from '@/lib/helper'; import { isPathActive } from '@/lib/helper';
type CollapseMenuProps = {
title: string;
link: string;
icon: string;
submenu?: CollapseMenuProps[];
depth?: number;
};
const isPathActive = (pathname: string, link?: string) => {
if (!link) return false;
const splittedPathname = pathname.split('/');
const splittedLink = link.split('/');
const isActiveLinkValid = splittedLink.every((linkChunk, idx) => {
return linkChunk === splittedPathname[idx];
});
return pathname.startsWith(link) && isActiveLinkValid;
};
const CollapseMenu = ({
title,
link,
icon,
submenu,
depth = 0,
}: CollapseMenuProps) => {
const pathname = usePathname();
const isActive = isPathActive(pathname, link);
const [open, setOpen] = useState(isActive);
const menuCollapseTitle = (
<div
className={cn(
'w-full px-3 py-2 rounded-md text-base font-semibold transition-colors flex flex-row justify-between items-center gap-2 hover:bg-primary/10 opacity-40',
{
'bg-primary/10 opacity-100': open || isActive,
}
)}
>
<div className='flex flex-row items-center gap-2'>
<Icon icon={icon} width={20} height={20} />
<span>{title}</span>
</div>
<Icon
icon='cuida:caret-up-outline'
width={20}
height={20}
className={cn('transition-transform', {
'rotate-90': !open,
'rotate-180': open,
})}
/>
</div>
);
return (
<Collapse
open={open}
title={menuCollapseTitle}
onOpenChange={setOpen}
className='w-full'
titleClassName='w-full p-0!'
>
<Menu>
<div
className='w-full py-0.5 flex flex-col gap-0.5'
style={{
paddingLeft: `${0.5 * (depth + 1)}rem`,
}}
>
{submenu?.map((item, idx) => {
const hasSubmenu = item.submenu && item.submenu.length > 0;
if (!hasSubmenu) {
return (
<MenuItem
key={idx}
title={item.title}
href={item.link}
icon={item.icon}
active={isPathActive(pathname, item.link)}
/>
);
}
return (
<CollapseMenu
key={idx}
title={item.title}
link={item.link}
icon={item.icon}
submenu={item.submenu}
depth={depth + 1}
/>
);
})}
</div>
</Menu>
</Collapse>
);
};
const MainDrawerMenu = () => {
const pathname = usePathname();
return (
<Menu>
{MAIN_DRAWER_LINKS.map((item, idx) => {
const hasSubmenu = item.submenu && item.submenu.length > 0;
if (!hasSubmenu) {
return (
<MenuItem
key={idx}
title={item.title}
href={item.link}
icon={item.icon}
active={pathname.startsWith(item.link)}
/>
);
}
return (
<CollapseMenu
key={idx}
title={item.title}
link={item.link}
icon={item.icon}
submenu={item.submenu}
/>
);
})}
</Menu>
);
};
const MainDrawerContent = () => { const MainDrawerContent = () => {
const pathname = usePathname();
const { setMainDrawerOpen } = useUiStore(); const { setMainDrawerOpen } = useUiStore();
const closeMainDrawerHandler = () => { const closeMainDrawerHandler = () => {
@@ -191,7 +51,7 @@ const MainDrawerContent = () => {
</div> </div>
</div> </div>
<MainDrawerMenu /> <SidebarMenu menu={MAIN_DRAWER_LINKS} activeLink={pathname} />
</div> </div>
); );
}; };
@@ -216,9 +76,9 @@ const MainDrawer = ({
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0; const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
if (!title) { if (!title) {
title += menu?.title; title += menu?.text;
} else { } else {
title += ' - ' + menu?.title; title += ' - ' + menu?.text;
} }
if (!hasSubmenu || !menu.submenu) return; if (!hasSubmenu || !menu.submenu) return;
+6 -2
View File
@@ -10,15 +10,19 @@ import {
} from 'react'; } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
export const useModal = () => { export const useModal = (isNestingModal = false) => {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const openModal = useCallback(() => { const openModal = useCallback(() => {
if (!ref.current) return; if (!ref.current) return;
if (isNestingModal) {
ref.current.showModal();
} else {
ref.current.show(); ref.current.show();
}
setOpen(true); setOpen(true);
}, []); }, [isNestingModal]);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
if (!ref.current) return; if (!ref.current) return;
+23 -2
View File
@@ -1,16 +1,38 @@
'use client'; 'use client';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth';
import { isResponseError } from '@/lib/api-helper';
interface NavbarProps { interface NavbarProps {
title: string; title: string;
toggleSidebar?: () => void; toggleSidebar?: () => void;
} }
const Navbar = ({ title, toggleSidebar }: NavbarProps) => { const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
const { setUser } = useAuth();
const router = useRouter();
const logoutClickHandler = async () => {
const logoutRes = await AuthApi.logout();
if (isResponseError(logoutRes)) {
toast.error('Gagal logout! Coba lagi!');
return;
}
setUser(undefined);
router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
};
return ( return (
<div className='navbar px-4 bg-base-100 shadow-sm'> <div className='navbar px-4 bg-base-100 shadow-sm'>
<div className='flex-1'> <div className='flex-1'>
@@ -42,8 +64,7 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
</div> </div>
<Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'> <Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'>
<MenuItem title='Settings' href='#' /> <MenuItem title='Logout' onClick={logoutClickHandler} />
<MenuItem title='Logout' href='#' />
</Menu> </Menu>
</div> </div>
</div> </div>
+164 -74
View File
@@ -1,7 +1,9 @@
'use client'; 'use client';
import { ReactNode } from 'react'; import { ChangeEventHandler, ReactNode } from 'react';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
@@ -17,16 +19,18 @@ const PaginationButton = ({
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
}) => ( }) => (
<button <Button
className={cn( variant='ghost'
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square', color='none'
'disabled:text-gray-700 disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-gray-50 disabled:active:translate-y-0'
)}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg text-sm font-semibold text-base-content/50 aspect-square',
'disabled:text-primary disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-primary/10 disabled:active:translate-y-0'
)}
> >
{content} {content}
</button> </Button>
); );
const EtcPaginationButton = ({ const EtcPaginationButton = ({
@@ -90,16 +94,20 @@ const Pagination = ({
currentPage = 1, currentPage = 1,
totalItems = 0, totalItems = 0,
itemsPerPage = 10, itemsPerPage = 10,
rowOptions = [10, 20, 50, 100],
onPageChange, onPageChange,
onPrevPage = () => {}, onPrevPage = () => {},
onNextPage = () => {}, onNextPage = () => {},
onRowChange,
}: { }: {
currentPage: number; currentPage: number;
totalItems: number; totalItems: number;
itemsPerPage: number; itemsPerPage: number;
rowOptions?: number[];
onPageChange: (pageNumber: number) => void; onPageChange: (pageNumber: number) => void;
onPrevPage: () => void; onPrevPage: () => void;
onNextPage: () => void; onNextPage: () => void;
onRowChange?: (row: number) => void;
}) => { }) => {
const totalPages = const totalPages =
Math.ceil(totalItems / itemsPerPage) === 0 Math.ceil(totalItems / itemsPerPage) === 0
@@ -107,30 +115,139 @@ const Pagination = ({
: Math.ceil(totalItems / itemsPerPage); : Math.ceil(totalItems / itemsPerPage);
const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber); const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber);
const firstPageClickHandler = () => onPageChange(1);
const lastPageClickHandler = () => onPageChange(totalPages);
return ( const rowChangeHandler: ChangeEventHandler<HTMLSelectElement> = (e) => {
<div> onRowChange?.(Number(e.target.value));
<div className='join w-full justify-between items-center gap-3'> };
<button
const DisplayedRowCountSelect = () => (
<div className='flex flex-row items-center gap-4'>
<span className='text-sm font-medium text-base-content/50'>Showing</span>
<select
defaultValue={itemsPerPage}
onChange={rowChangeHandler}
className='select select-xs w-fit text-base-content/50'
>
{rowOptions.map((rowOption, rowOptionIdx) => (
<option
key={rowOptionIdx}
value={rowOption}
className='text-base-content active:text-neutral-content'
>
{rowOption} Per page
</option>
))}
</select>
</div>
);
const GoToFirstPageButton = () => (
<Button
disabled={currentPage === 1} disabled={currentPage === 1}
onClick={onPrevPage} onClick={firstPageClickHandler}
variant='ghost'
color='none'
className={cn( className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5', 'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0' 'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)} )}
> >
<Icon <Icon
icon='uil:arrow-left' icon='heroicons:chevron-double-left'
width={20} width={20}
height={20} height={20}
className='text-gray-400 group-disabled:text-gray-300' className='text-gray-400 group-disabled:text-gray-300'
/>{' '} />
Previous </Button>
</button> );
{totalPages <= 7 && ( const PrevPageButton = () => (
<div className='join-item join gap-0.5'> <Button
{range(1, totalPages).map((pageNumber) => ( disabled={currentPage === 1}
onClick={onPrevPage}
variant='ghost'
color='none'
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const GoToLastPageButton = () => (
<Button
variant='ghost'
color='none'
disabled={currentPage === totalPages}
onClick={lastPageClickHandler}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-double-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const NextPageButton = () => (
<Button
variant='ghost'
color='none'
disabled={currentPage === totalPages}
onClick={onNextPage}
className={cn(
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg text-sm font-semibold text-base-content/50 aspect-square',
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='heroicons:chevron-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</Button>
);
const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'>
Page {currentPage} of {totalPages}
</span>
);
return (
<div className='@container'>
<div className='flex flex-row justify-center items-center'>
<div className='hidden @lg:block'>
<DisplayedRowCountSelect />
</div>
<div className='join w-full justify-end @lg:justify-center items-center gap-0.5'>
<div className='hidden @lg:block'>
<GoToFirstPageButton />
</div>
<div className='hidden @lg:block'>
<PrevPageButton />
</div>
{totalPages <= 7 &&
range(1, totalPages).map((pageNumber) => (
<PaginationButton <PaginationButton
key={pageNumber} key={pageNumber}
content={pageNumber} content={pageNumber}
@@ -138,11 +255,9 @@ const Pagination = ({
onClick={() => pageChangeHandler(pageNumber)} onClick={() => pageChangeHandler(pageNumber)}
/> />
))} ))}
</div>
)}
{totalPages > 7 && ( {totalPages > 7 && (
<div className='join-item join gap-0.5'> <>
<PaginationButton <PaginationButton
content={1} content={1}
disabled={currentPage === 1} disabled={currentPage === 1}
@@ -272,61 +387,36 @@ const Pagination = ({
onClick={() => pageChangeHandler(totalPages)} onClick={() => pageChangeHandler(totalPages)}
/> />
)} )}
</div> </>
)} )}
<button <div className='hidden @lg:block'>
disabled={currentPage === totalPages} <NextPageButton />
onClick={onNextPage}
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
Next{' '}
<Icon
icon='uil:arrow-right'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>
</button>
</div> </div>
<div className='flex gap-2 mt-2 sm:hidden'> <div className='hidden @lg:block'>
<button <GoToLastPageButton />
disabled={currentPage === 1} </div>
onClick={onPrevPage} </div>
className={cn(
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
)}
>
<Icon
icon='uil:arrow-left'
width={20}
height={20}
className='text-gray-400 group-disabled:text-gray-300'
/>{' '}
Previous
</button>
<button <div className='hidden @lg:block'>
disabled={currentPage === totalPages} <PageInfo />
onClick={onNextPage} </div>
className={cn( </div>
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5',
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0' <div className='flex @lg:hidden flex-col justify-center items-end gap-2'>
)} <div className='flex flex-row items-center gap-0.5'>
> <GoToFirstPageButton />
Next{' '} <PrevPageButton />
<Icon <NextPageButton />
icon='uil:arrow-right' <GoToLastPageButton />
width={20} </div>
height={20}
className='text-gray-400 group-disabled:text-gray-300' <div className='flex flex-row items-center gap-4'>
/> <DisplayedRowCountSelect />
</button>
<PageInfo />
</div>
</div> </div>
</div> </div>
); );
+59 -29
View File
@@ -38,6 +38,7 @@ export interface TableProps<TData extends object> {
data: TData[]; data: TData[];
columns: ColumnDef<TData, unknown>[]; columns: ColumnDef<TData, unknown>[];
pageSize?: number; pageSize?: number;
onPageSizeChange?: (pageSize: number) => void;
totalItems?: number; totalItems?: number;
page?: number; page?: number;
onPageChange?: (page: number) => void; onPageChange?: (page: number) => void;
@@ -52,6 +53,8 @@ export interface TableProps<TData extends object> {
rowSelection?: Record<string, boolean>; rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>; setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean); enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
withCheckbox?: boolean;
rowOptions?: number[];
} }
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -64,28 +67,32 @@ const emptyContentDefaultValue = (
</div> </div>
); );
const TABLE_DEFAULT_STYLING = {
containerClassName: 'w-full mb-20',
tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-lg',
tableClassName: 'font-inter w-full table-auto text-sm font-medium',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName: 'px-4 py-3 text-base-content/50',
tableBodyClassName: '',
bodyRowClassName: 'border-t border-t-base-content/10',
bodyColumnClassName: 'px-4 py-3 text-base-content',
paginationClassName: '',
};
const Table = <TData extends object>({ const Table = <TData extends object>({
data = [], data = [],
columns = [], columns = [],
pageSize = 10, pageSize = 10,
onPageSizeChange,
totalItems, totalItems,
page, page,
onPageChange, onPageChange,
isLoading = false, isLoading = false,
fuzzySearchValue, fuzzySearchValue,
onFuzzySearchValueChange, onFuzzySearchValueChange,
className = { className = TABLE_DEFAULT_STYLING,
containerClassName: '',
tableWrapperClassName: '',
tableClassName: '',
tableHeaderClassName: '',
headerRowClassName: '',
headerColumnClassName: '',
tableBodyClassName: '',
bodyRowClassName: '',
bodyColumnClassName: '',
paginationClassName: '',
},
emptyContent = emptyContentDefaultValue, emptyContent = emptyContentDefaultValue,
sorting, sorting,
setSorting, setSorting,
@@ -93,12 +100,19 @@ const Table = <TData extends object>({
rowSelection, rowSelection,
setRowSelection, setRowSelection,
enableRowSelection, enableRowSelection,
withCheckbox = false,
rowOptions = [10, 20, 50, 100],
}: TableProps<TData>) => { }: TableProps<TData>) => {
const isServerSideTable = const isServerSideTable =
totalItems !== undefined && totalItems !== undefined &&
page !== undefined && page !== undefined &&
onPageChange !== undefined; onPageChange !== undefined;
const tableClassNames = {
...TABLE_DEFAULT_STYLING,
...className,
};
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
pageSize: pageSize, pageSize: pageSize,
@@ -191,12 +205,15 @@ const Table = <TData extends object>({
}, [pageSize, setPageSize]); }, [pageSize, setPageSize]);
return ( return (
<div className={className.containerClassName}> <div className={tableClassNames.containerClassName}>
<div className={className.tableWrapperClassName}> <div className={tableClassNames.tableWrapperClassName}>
<table className={className.tableClassName}> <table className={tableClassNames.tableClassName}>
<thead className={className.tableHeaderClassName}> <thead className={tableClassNames.tableHeaderClassName}>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className={className.headerRowClassName}> <tr
key={headerGroup.id}
className={tableClassNames.headerRowClassName}
>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th <th
key={header.id} key={header.id}
@@ -206,7 +223,10 @@ const Table = <TData extends object>({
header.column.getCanSort() header.column.getCanSort()
? 'cursor-pointer select-none' ? 'cursor-pointer select-none'
: '', : '',
className.headerColumnClassName {
'first:w-9 first:pr-0': withCheckbox,
},
tableClassNames.headerColumnClassName
)} )}
> >
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
@@ -216,12 +236,13 @@ const Table = <TData extends object>({
)} )}
{header.column.getCanSort() && ( {header.column.getCanSort() && (
<div className='flex items-center'> <div className='w-4 h-4 relative flex flex-col items-center'>
<Icon <Icon
icon='lucide:arrow-up' icon='heroicons:chevron-up-16-solid'
width={12} width={18}
height={12} height={18}
className={cn( className={cn(
'absolute -top-1',
'transition-all ease-in-out duration-200', 'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'asc' header.column.getIsSorted() === 'asc'
? 'text-black' ? 'text-black'
@@ -229,10 +250,11 @@ const Table = <TData extends object>({
)} )}
/> />
<Icon <Icon
icon='lucide:arrow-down' icon='heroicons:chevron-down-16-solid'
width={12} width={18}
height={12} height={18}
className={cn( className={cn(
'absolute -bottom-1.5',
'transition-all ease-in-out duration-200', 'transition-all ease-in-out duration-200',
header.column.getIsSorted() === 'desc' header.column.getIsSorted() === 'desc'
? 'text-black' ? 'text-black'
@@ -248,11 +270,17 @@ const Table = <TData extends object>({
))} ))}
</thead> </thead>
<tbody className={className.tableBodyClassName}> <tbody className={tableClassNames.tableBodyClassName}>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr key={row.id} className={className.bodyRowClassName}> <tr key={row.id} className={tableClassNames.bodyRowClassName}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className={className.bodyColumnClassName}> <td
key={cell.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.bodyColumnClassName
)}
>
{!isLoading && {!isLoading &&
flexRender(cell.column.columnDef.cell, cell.getContext())} flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -270,7 +298,7 @@ const Table = <TData extends object>({
emptyContent} emptyContent}
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && ( {data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
<div className={cn('mt-5', className.paginationClassName)}> <div className={cn('mt-5', tableClassNames.paginationClassName)}>
<Pagination <Pagination
totalItems={isServerSideTable ? totalItems : table.getRowCount()} totalItems={isServerSideTable ? totalItems : table.getRowCount()}
itemsPerPage={table.getState().pagination.pageSize} itemsPerPage={table.getState().pagination.pageSize}
@@ -282,6 +310,8 @@ const Table = <TData extends object>({
onPrevPage={prevPageClickHandler} onPrevPage={prevPageClickHandler}
onNextPage={nextPageClickHandler} onNextPage={nextPageClickHandler}
onPageChange={pageChangeHandler} onPageChange={pageChangeHandler}
rowOptions={rowOptions}
onRowChange={onPageSizeChange}
/> />
</div> </div>
)} )}
+29 -162
View File
@@ -6,147 +6,9 @@ import useSWRImmutable from 'swr/immutable';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { GetMeResponse } from '@/types/api/api-general'; import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios';
// TODO: delete this later, DONT HARDCODE USER DATA
const DUMMY_USER = {
id: 1,
email: 'admin@mbugroup.id',
npk: '0001',
name: 'Super Admin',
image: null,
created_at: '2025-09-30T03:24:20.899229Z',
updated_at: '2025-09-30T03:24:20.899229Z',
roles: [
{
id: 1,
key: 'mbu.super_admin',
name: 'MBU Administrator',
client: {
id: 1,
name: 'PT Mitra Berlian Unggas',
alias: 'MBU',
},
permissions: [
{
id: 1,
name: 'mbu:purchase:read',
action: 'read',
client: {
id: 1,
name: 'PT Mitra Berlian Unggas',
alias: 'MBU',
},
},
{
id: 2,
name: 'mbu:purchase:create',
action: 'create',
client: {
id: 1,
name: 'PT Mitra Berlian Unggas',
alias: 'MBU',
},
},
{
id: 3,
name: 'mbu:purchase:approve',
action: 'approve',
client: {
id: 1,
name: 'PT Mitra Berlian Unggas',
alias: 'MBU',
},
},
],
},
{
id: 2,
key: 'lti.super_admin',
name: 'LTI Administrator',
client: {
id: 2,
name: 'PT Lumbung Telur Indonesia',
alias: 'LTI',
},
permissions: [
{
id: 4,
name: 'lti:purchase:read',
action: 'read',
client: {
id: 2,
name: 'PT Lumbung Telur Indonesia',
alias: 'LTI',
},
},
{
id: 5,
name: 'lti:purchase:create',
action: 'create',
client: {
id: 2,
name: 'PT Lumbung Telur Indonesia',
alias: 'LTI',
},
},
{
id: 6,
name: 'lti:purchase:approve',
action: 'approve',
client: {
id: 2,
name: 'PT Lumbung Telur Indonesia',
alias: 'LTI',
},
},
],
},
{
id: 3,
key: 'manbu.super_admin',
name: 'MANBU Administrator',
client: {
id: 3,
name: 'PT Mandiri Berlian Unggas',
alias: 'MANBU',
},
permissions: [
{
id: 7,
name: 'manbu:purchase:read',
action: 'read',
client: {
id: 3,
name: 'PT Mandiri Berlian Unggas',
alias: 'MANBU',
},
},
{
id: 8,
name: 'manbu:purchase:create',
action: 'create',
client: {
id: 3,
name: 'PT Mandiri Berlian Unggas',
alias: 'MANBU',
},
},
{
id: 9,
name: 'manbu:purchase:approve',
action: 'approve',
client: {
id: 3,
name: 'PT Mandiri Berlian Unggas',
alias: 'MANBU',
},
},
],
},
],
};
interface RequireAuthProps { interface RequireAuthProps {
children?: ReactNode; children?: ReactNode;
@@ -156,17 +18,20 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
const router = useRouter(); const router = useRouter();
const { setUser, setIsLoadingUser } = useAuth(); const { setUser, setIsLoadingUser } = useAuth();
const { data: userResponse, isLoading: isLoadingUserResponse } = const {
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>( data: userResponse,
'/auth/sso/userinfo', isLoading: isLoadingUserResponse,
httpClientFetcher, error: userErrorResponse,
{ } = useSWRImmutable<
GetMeResponse & { ok?: boolean },
AxiosError<BaseApiResponse>,
SWRHttpKey
>('/sso/userinfo', httpClientFetcher, {
shouldRetryOnError: false, shouldRetryOnError: false,
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: false, revalidateOnReconnect: false,
refreshInterval: 0, refreshInterval: 0,
} });
);
useEffect(() => { useEffect(() => {
setIsLoadingUser(isLoadingUserResponse); setIsLoadingUser(isLoadingUserResponse);
@@ -175,23 +40,25 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
useEffect(() => { useEffect(() => {
if (isResponseSuccess(userResponse)) { if (isResponseSuccess(userResponse)) {
setUser(userResponse.data); setUser(userResponse.data);
} else { } else if (
// router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string); isResponseError(userErrorResponse?.response?.data) &&
// TODO: remove this later, DONT HARDCODE USER DATA typeof window !== 'undefined'
setUser(DUMMY_USER); ) {
router.replace(
`${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`
);
} }
}, [userResponse, setIsLoadingUser, setUser]); }, [userResponse, userErrorResponse, setIsLoadingUser, setUser]);
// TODO: uncomment this later if (isLoadingUserResponse && !userResponse && !userErrorResponse) {
// if (isLoadingUserResponse && !userResponse) { return (
// return ( <div className='w-full flex flex-row justify-center items-center p-4'>
// <div className='w-full flex flex-row justify-center items-center p-4'> <span className='loading loading-spinner loading-xl' />
// <span className='loading loading-spinner loading-xl' /> </div>
// </div> );
// ); }
// }
return <>{children}</>; return <>{isResponseSuccess(userResponse) && children}</>;
}; };
export default RequireAuth; export default RequireAuth;
+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' && (
+13 -2
View File
@@ -2,8 +2,9 @@
import { HTMLProps, useEffect, useRef } from 'react'; import { HTMLProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> { interface CheckboxInputProps extends Omit<HTMLProps<HTMLInputElement>, 'size'> {
name: string; name: string;
label?: string; label?: string;
indeterminate?: boolean; indeterminate?: boolean;
@@ -16,6 +17,7 @@ interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
isError?: boolean; isError?: boolean;
isValid?: boolean; isValid?: boolean;
errorMessage?: string; errorMessage?: string;
size?: Size;
} }
const CheckboxInput = ({ const CheckboxInput = ({
@@ -27,10 +29,19 @@ const CheckboxInput = ({
isValid, isValid,
isError, isError,
errorMessage, errorMessage,
size = 'sm',
...rest ...rest
}: CheckboxInputProps) => { }: CheckboxInputProps) => {
const ref = useRef<HTMLInputElement>(null!); const ref = useRef<HTMLInputElement>(null!);
const checkboxBaseClassName = cn('checkbox cursor-pointer rounded-md', {
'checkbox-xs': size === 'xs',
'checkbox-sm': size === 'sm',
'checkbox-md': size === 'md',
'checkbox-lg': size === 'lg',
'checkbox-xl': size === 'xl',
});
useEffect(() => { useEffect(() => {
if (typeof indeterminate === 'boolean') { if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !rest.checked && indeterminate; ref.current.indeterminate = !rest.checked && indeterminate;
@@ -53,7 +64,7 @@ const CheckboxInput = ({
id={name} id={name}
name={name} name={name}
className={cn( className={cn(
'checkbox cursor-pointer', checkboxBaseClassName,
{ {
'border-error': isError, 'border-error': isError,
'border-success': isValid, 'border-success': isValid,
+11 -6
View File
@@ -7,10 +7,10 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '../Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker'; import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css'; import 'react-day-picker/dist/style.css';
import Button from '@/components/Button'; import Button from '../Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
export interface DateInputProps { export interface DateInputProps {
@@ -34,6 +34,7 @@ export interface DateInputProps {
required?: boolean; required?: boolean;
isLoading?: boolean; isLoading?: boolean;
isRange?: boolean; isRange?: boolean;
isNestedModal?: boolean; // New prop to indicate if used inside another modal
errorMessage?: string; errorMessage?: string;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
@@ -58,6 +59,7 @@ const DateInput = ({
readOnly = false, readOnly = false,
isLoading = false, isLoading = false,
isRange = false, isRange = false,
isNestedModal = false,
}: DateInputProps) => { }: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null); const [internalError, setInternalError] = useState<string | null>(null);
const [selected, setSelected] = useState<Date | undefined>(); const [selected, setSelected] = useState<Date | undefined>();
@@ -74,11 +76,14 @@ const DateInput = ({
? new Date(max.split('/').reverse().join('-')) ? new Date(max.split('/').reverse().join('-'))
: undefined; : undefined;
const calendarModal = useModal(); const calendarModal = useModal(isNestedModal);
// --- Sync value props --- // --- Sync value props ---
useEffect(() => { useEffect(() => {
if (!value) return; if (!value) {
setDisplayValue('');
return;
}
if (isRange && typeof value === 'object') { if (isRange && typeof value === 'object') {
const from = value.from ? new Date(value.from) : undefined; const from = value.from ? new Date(value.from) : undefined;
const to = value.to ? new Date(value.to) : undefined; const to = value.to ? new Date(value.to) : undefined;
@@ -210,7 +215,7 @@ const DateInput = ({
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border', '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': finalIsError, 'border-error': finalIsError,
'border-success': externalValid && !finalIsError, 'border-success': externalValid && !finalIsError,
@@ -261,7 +266,7 @@ const DateInput = ({
ref={calendarModal.ref} ref={calendarModal.ref}
className={{ className={{
modal: 'rounded', modal: 'rounded',
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`, modalBox: `!max-w-max min-h-${isRange ? '124' : '110'} flex flex-col`,
}} }}
closeOnBackdrop closeOnBackdrop
> >
@@ -0,0 +1,44 @@
'use client';
import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';
import TextArea, { TextAreaProps } from '@/components/input/TextArea';
interface DebouncedTextAreaProps extends TextAreaProps {
delay?: number;
}
const DebouncedTextArea = (props: DebouncedTextAreaProps) => {
const { delay, onChange } = props;
const [internalChangeEvent, setInternalChangeEvent] =
useState<ChangeEvent<HTMLTextAreaElement>>();
const [internalValue, setInternalValue] = useState(props.value);
const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300);
const [debouncedValue] = useDebounce(internalValue, delay ?? 300);
const internalChangeHandler: ChangeEventHandler<HTMLTextAreaElement> = (
e
) => {
setInternalValue(e.target.value);
setInternalChangeEvent(e);
};
useEffect(() => {
if (debouncedChangeEvent) {
onChange?.(debouncedChangeEvent);
}
}, [debouncedValue]);
return (
<TextArea
{...props}
value={internalValue}
onChange={internalChangeHandler}
/>
);
};
export default DebouncedTextArea;
+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}
/> />
); );
+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>
+20 -4
View File
@@ -1,16 +1,32 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Size } from '@/types/theme';
interface MenuProps { interface MenuProps {
children?: ReactNode; children?: ReactNode;
size?: Size;
direction?: 'vertical' | 'horizontal';
className?: string; className?: string;
} }
const Menu = ({ children, className }: MenuProps) => { const Menu = ({
return ( children,
<ul className={cn('menu w-full p-0 gap-0.5', className)}>{children}</ul> size = 'md',
); direction = 'vertical',
className,
}: MenuProps) => {
const menuBaseClassName = cn('menu w-full', {
'menu-xs': size === 'xs',
'menu-sm': size === 'sm',
'menu-md': size === 'md',
'menu-lg': size === 'lg',
'menu-xl': size === 'xl',
'menu-vertical': direction === 'vertical',
'menu-horizontal': direction === 'horizontal',
});
return <ul className={cn(menuBaseClassName, className)}>{children}</ul>;
}; };
export default Menu; export default Menu;
@@ -50,6 +50,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
...primaryButton, ...primaryButton,
onClick: () => { onClick: () => {
primaryButton?.onClick?.(notes); primaryButton?.onClick?.(notes);
setNotes('');
}, },
}} }}
secondaryButton={secondaryButton} secondaryButton={secondaryButton}
+92
View File
@@ -0,0 +1,92 @@
import Link from 'next/link';
import Menu from '@/components/menu/Menu';
import { Icon } from '@iconify/react';
import { cn, isPathActive } from '@/lib/helper';
export interface SidebarMenuItem {
type?: 'item' | 'title';
text: string;
link: string;
icon?: string;
submenu?: SidebarMenuItem[];
}
interface SidebarMenuItemProps {
item: SidebarMenuItem;
activeLink: string;
}
interface SidebarMenuProps {
menu: SidebarMenuItem[];
activeLink: string;
}
const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
const isItemActive = isPathActive(activeLink, item.link);
const menuItemWithoutSubmenu = (
<li>
<Link
href={item.link}
className={cn(
{
'menu-active border-2 border-solid border-base-300': isItemActive,
},
'px-3 py-1.5'
)}
>
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span>
</Link>
</li>
);
if (!item.submenu || item.submenu.length === 0) {
return menuItemWithoutSubmenu;
}
const menuItemWithSubmenu = (
<li>
<details open={isItemActive}>
<summary
className={cn({
'text-primary': isItemActive,
})}
>
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
<span className='text-base'>{item.text}</span>
</summary>
<ul>
{item.submenu.map((submenuItem, submenuIdx) => (
<SidebarMenuItem
key={`submenu#${submenuIdx}`}
item={submenuItem}
activeLink={activeLink}
/>
))}
</ul>
</details>
</li>
);
return menuItemWithSubmenu;
};
const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => {
return (
<Menu>
{menu.map((menuItem, menuIdx) => (
<SidebarMenuItem
key={menuIdx}
item={menuItem}
activeLink={activeLink}
/>
))}
</Menu>
);
};
export default SidebarMenu;
+41 -6
View File
@@ -18,6 +18,7 @@ import { useCallback, useMemo } from 'react';
export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE'; export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE';
export type ApprovalStepLog = { export type ApprovalStepLog = {
action: string;
action_by?: string; action_by?: string;
date?: string; date?: string;
notes?: string | null; notes?: string | null;
@@ -65,15 +66,40 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => {
position='right' position='right'
className={{ className={{
wrapper: 'md:tooltip-bottom', wrapper: 'md:tooltip-bottom',
content: 'p-0 rounded overflow-hidden',
}} }}
content={ content={
<> <>
{approval.logs && approval.logs.length > 0 && ( {approval.logs && approval.logs.length > 0 && (
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-0'>
{approval.logs?.map((approvalLog, logIdx) => ( {approval.logs?.map((approvalLog, logIdx) => {
const action =
approvalLog.action === 'CREATED'
? 'Dibuat'
: approvalLog.action === 'UPDATED'
? 'Diperbarui'
: approvalLog.action === 'APPROVED'
? 'Disetujui'
: approvalLog.action === 'REJECTED'
? 'Ditolak'
: '-';
return (
<div <div
key={logIdx} key={logIdx}
className='flex flex-col text-base text-start' className={cn(
'p-2 flex flex-col text-base text-start',
{
'bg-success text-success-content':
approvalLog.action === 'APPROVED',
'bg-error text-error-content':
approvalLog.action === 'REJECTED',
'bg-info text-info-content':
approvalLog.action === 'CREATED',
'bg-warning text-warning-content':
approvalLog.action === 'UPDATED',
}
)}
> >
{approvalLog.date && ( {approvalLog.date && (
<span> <span>
@@ -83,10 +109,12 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => {
)} )}
</span> </span>
)} )}
<span>Aksi: {action}</span>
<span>Oleh: {approvalLog.action_by ?? '-'}</span> <span>Oleh: {approvalLog.action_by ?? '-'}</span>
<span>Catatan: {approvalLog.notes ?? '-'}</span> <span>Catatan: {approvalLog.notes ?? '-'}</span>
</div> </div>
))} );
})}
</div> </div>
)} )}
</> </>
@@ -130,6 +158,8 @@ export const formatGroupedApprovalsToApprovalSteps = (
const lastStepNumber = const lastStepNumber =
groupedApprovals[groupedApprovals.length - 1]?.step_number; groupedApprovals[groupedApprovals.length - 1]?.step_number;
const isLatestApprovalRejected = latestApproval.action === 'REJECTED';
if (!approvalGroup && currentStepNumber <= lastStepNumber) { if (!approvalGroup && currentStepNumber <= lastStepNumber) {
throw new Error( throw new Error(
`Approval dengan ${approvalLineItem.step_name} tidak ditemukan!` `Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
@@ -158,6 +188,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
if (approvalGroup.approvals) { if (approvalGroup.approvals) {
switch (approvalGroup?.approvals[0]?.action) { switch (approvalGroup?.approvals[0]?.action) {
case 'CREATED': case 'CREATED':
case 'UPDATED':
case 'APPROVED': case 'APPROVED':
approvalStatus = 'APPROVED'; approvalStatus = 'APPROVED';
break; break;
@@ -171,7 +202,10 @@ export const formatGroupedApprovalsToApprovalSteps = (
break; break;
} }
} }
} else if (approvalGroup.step_number === latestApproval.step_number + 1) { } else if (
approvalGroup.step_number === latestApproval.step_number + 1 &&
!isLatestApprovalRejected
) {
approvalStatus = 'WAITING'; approvalStatus = 'WAITING';
} else { } else {
approvalStatus = 'IDLE'; approvalStatus = 'IDLE';
@@ -182,6 +216,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
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,
action: approval.action,
})) }))
: []; : [];
@@ -256,7 +291,7 @@ const useApprovalSteps = ({
moduleName: string; moduleName: string;
moduleId: string; moduleId: string;
params?: { params?: {
page: number; page?: number;
limit: number; limit: number;
search?: string; search?: string;
group_step_number?: boolean; group_step_number?: boolean;
+29 -460
View File
@@ -1,157 +1,45 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, 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 { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useModal } from '@/components/Modal'; import Tabs from '@/components/Tabs';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestContent';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent';
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 { 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 { interface ExpenseDetailProps {
initialValues?: Expense; initialValues?: Expense;
} }
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => { const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter(); const [activeTab, setActiveTab] = useState<string>('request');
// Modal hooks const expenseDetailTabs = useMemo(() => {
const deleteModal = useModal(); const validTabs = [
const approveModal = useModal(); {
const rejectModal = useModal(); id: 'request',
label: 'Pengajuan',
// Modal loading state content: <ExpenseRequestContent initialValues={initialValues} />,
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)) { if (
toast.success(addRequestDocumentsRes.message); initialValues?.latest_approval &&
window.location.reload(); initialValues?.latest_approval.step_number >= 4 &&
} else { initialValues.latest_approval.action !== 'REJECTED'
toast.error(String(addRequestDocumentsRes?.message)); ) {
} validTabs.push({
}, id: 'realization',
label: 'Realisasi',
content: <ExpenseRealizationContent initialValues={initialValues} />,
}); });
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); return validTabs;
}; }, [initialValues]);
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 ( return (
<> <>
@@ -171,335 +59,16 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
</h1> </h1>
</header> </header>
<div className='w-full mt-4 flex flex-col gap-4'> <Tabs
{/* TODO: apply RBAC */} activeTabId={activeTab}
{!isLatestApprovalRejectedOrDone && ( onTabChange={setActiveTab}
<div className='w-full max-w-3xl mx-auto flex flex-row justify-end gap-2'> tabs={expenseDetailTabs}
<Button variant='lifted'
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={{ className={{
wrapper: 'mt-2', wrapper: 'max-w-5xl mx-auto mt-4',
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> </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,
}}
/>
</> </>
); );
}; };
@@ -0,0 +1,327 @@
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Card from '@/components/Card';
import DropFileInput from '@/components/input/DropFileInput';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper';
import {
UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
interface ExpenseRealizationContentProps {
initialValues?: Expense;
}
const ExpenseRealizationContent = ({
initialValues,
}: ExpenseRealizationContentProps) => {
const formik = useFormik<UploadRequestDocumentsFormValues>({
initialValues: {
documents: [],
},
validationSchema: UploadRequestDocumentsFormSchema,
onSubmit: async (values) => {
const addRealizationDocumentsRes =
await ExpenseApi.uploadRealizationDocuments(
initialValues?.id as number,
values.documents
);
if (isResponseSuccess(addRealizationDocumentsRes)) {
toast.success(addRealizationDocumentsRes.message);
window.location.reload();
} else {
toast.error(String(addRealizationDocumentsRes?.message));
}
},
});
const realizationDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('documents', true);
formik.setFieldValue('documents', val);
};
const realizationDocumentsDeleteHandler = (deletedFileIdx: number) => {
const newRealizationDocuments = formik.values.documents;
newRealizationDocuments?.splice(deletedFileIdx, 1);
formik.setFieldValue('documents', newRealizationDocuments);
};
return (
<div>
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{/* TODO: apply RBAC */}
<Button
type='button'
color='warning'
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
className='px-4 grow sm:grow-0'
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit Realisasi
</Button>
</div>
</div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
<table className='table table-sm table-zebra'>
<tbody>
<tr>
<th>Tanggal Realisasi</th>
<th>:</th>
<td>
{initialValues?.realization_date
? formatDate(initialValues?.realization_date, 'DD MMMM YYYY')
: '-'}
</td>
</tr>
<tr>
<th>Dokumen Realisasi</th>
<th>:</th>
<td>
<div>
{!initialValues?.realization_docs ||
(initialValues?.realization_docs &&
initialValues?.realization_docs.length === 0 &&
'-')}
{initialValues?.realization_docs &&
initialValues?.realization_docs.length > 0 && (
<ul className='list-disc'>
{initialValues?.realization_docs.map(
(realizationDocument, realizationDocumentIdx) => (
<li key={realizationDocumentIdx}>
<Link
href={realizationDocument.path}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{realizationDocument.path}{' '}
<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='documents'
values={formik.values.documents}
onChange={realizationDocumentsChangeHandler}
onDelete={realizationDocumentsDeleteHandler}
accept={{
...ACCEPTED_FILE_TYPE.PDF,
...ACCEPTED_FILE_TYPE.IMAGE,
}}
maxFiles={10}
className={{
wrapper: 'mt-2',
inputWrapper: 'flex items-center',
}}
/>
{formik.values.documents &&
formik.values.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 className='w-full max-w-5xl mt-8 mx-auto'>
<div className='flex flex-row gap-4'>
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
<div className='w-full flex flex-col gap-2'>
<h3 className='text-sm'>Nominal Pengajuan</h3>
<span className='text-xl'>
{formatCurrency(initialValues?.total_pengajuan as number)}
</span>
<span className='text-sm'>
Terbayar{' '}
{formatCurrency(initialValues?.total_realisasi as number)}
</span>
</div>
</Card>
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
<div className='w-full flex flex-col gap-2'>
<h3 className='text-sm'>Nominal Realisasi</h3>
<span className='text-xl'>
{formatCurrency(initialValues?.total_realisasi as number)}
</span>
<span className='text-sm'>
Selisih{' '}
{formatCurrency(
(initialValues?.total_realisasi as number) -
(initialValues?.total_pengajuan as number)
)}
</span>
</div>
</Card>
</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?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price)
);
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.name}
</th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.pengajuans?.map(
(pengajuanItem, pengajuanIdx) => (
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.total_price)}</td>
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
</tr>
</tfoot>
</table>
</div>
);
})}
</div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.total_price)
);
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.name}
</th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.realisasi?.map(
(realisasiItem, realisasiIdx) => (
<tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.total_price)}</td>
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
</tr>
</tfoot>
</table>
</div>
);
})}
</div>
</div>
</div>
);
};
export default ExpenseRealizationContent;
@@ -0,0 +1,655 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import useSWR from 'swr';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import Button from '@/components/Button';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import DropFileInput from '@/components/input/DropFileInput';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper';
import {
UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
import { BaseApiResponse } from '@/types/api/api-general';
interface ExpenseRequestContentProps {
initialValues?: Expense;
}
const ExpenseRequestContent = ({
initialValues,
}: ExpenseRequestContentProps) => {
const router = useRouter();
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
useApprovalSteps({
latestApproval: initialValues?.latest_approval,
approvalLines: EXPENSE_REQUEST_APPROVAL_LINE,
moduleName: 'EXPENSES',
moduleId: initialValues?.id.toString() ?? '',
params: {
page: 1,
limit: 100,
},
});
const isLatestApprovalRejected =
initialValues?.latest_approval.action === 'REJECTED';
const isLatestApprovalRejectedOrDone =
isLatestApprovalRejected ||
initialValues?.latest_approval.step_number === 5;
const isCurrentApprovalOnManager =
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 1;
const isCurrentApprovalOnFinance =
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 2;
const isCurrentApprovalOnRealization =
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 4;
const showEditButton =
initialValues?.latest_approval.step_number !== 5 &&
(initialValues?.latest_approval.step_number === 1 ||
initialValues?.latest_approval.step_number === 2 ||
initialValues?.latest_approval.step_number === 3);
const showRejectButton =
!isLatestApprovalRejected &&
(initialValues?.latest_approval.step_number === 1 ||
initialValues?.latest_approval.step_number === 2);
const isExpenseCanBeRealized =
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 3;
// Modal hooks
const deleteModal = useModal();
const completeModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
// Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const formik = useFormik<UploadRequestDocumentsFormValues>({
initialValues: {
documents: [],
},
validationSchema: UploadRequestDocumentsFormSchema,
onSubmit: async (values) => {
const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments(
initialValues?.id as number,
values.documents
);
if (isResponseSuccess(addRequestDocumentsRes)) {
toast.success(addRequestDocumentsRes.message);
window.location.reload();
} else {
toast.error(String(addRequestDocumentsRes?.message));
}
},
});
const deleteExpenseClickHandler = () => {
deleteModal.openModal();
};
const completeExpenseClickHandler = () => {
completeModal.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 confirmationModalCompleteClickHandler = async () => {
setIsCompleteLoading(true);
const completeRes = await ExpenseApi.complete(initialValues?.id as number);
if (isResponseSuccess(completeRes)) {
toast.success(completeRes.message);
router.push('/expense');
} else {
toast.error(completeRes?.message as string);
}
completeModal.closeModal();
setIsCompleteLoading(false);
};
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true);
let approveResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isCurrentApprovalOnManager) {
approveResponse = await ExpenseApi.approveManager(
initialValues.id,
notes
);
}
if (isCurrentApprovalOnFinance) {
approveResponse = await ExpenseApi.approveFinance(
initialValues.id,
notes
);
}
if (isResponseSuccess(approveResponse)) {
approveModal.closeModal();
toast.success(approveResponse?.message);
router.push('/expense');
} else {
approveModal.closeModal();
toast.error(approveResponse?.message as string);
}
setIsApproveLoading(false);
};
const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true);
let rejectResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isCurrentApprovalOnManager) {
rejectResponse = await ExpenseApi.rejectManager(initialValues.id, notes);
}
if (isCurrentApprovalOnFinance) {
rejectResponse = await ExpenseApi.rejectFinance(initialValues.id, notes);
}
if (isResponseSuccess(rejectResponse)) {
rejectModal.closeModal();
toast.success(rejectResponse.message);
router.push('/expense');
} else {
rejectModal.closeModal();
toast.error(rejectResponse?.message as string);
}
setIsRejectLoading(false);
};
const requestDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('documents', true);
formik.setFieldValue('documents', val);
};
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
const newRequestDocuments = formik.values.documents;
newRequestDocuments?.splice(deletedFileIdx, 1);
formik.setFieldValue('documents', newRequestDocuments);
};
return (
<>
<div>
{initialValues && !isLoadingApprovalHistory && approvalHistory && (
<div className='w-full max-w-5xl my-4 mx-auto'>
<ApprovalSteps approvals={approvalHistory} />
</div>
)}
<div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */}
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnManager && (
<Button
variant='outline'
color='info'
onClick={approveClickHandler}
className='w-full sm:w-fit'
>
<Icon icon='lucide-lab:farm' width={24} height={24} />
Approve Manager
</Button>
)}
{isCurrentApprovalOnFinance && (
<Button
variant='outline'
color='success'
onClick={approveClickHandler}
className='w-full sm:w-fit'
>
<Icon icon='tdesign:money' width={24} height={24} />
Approve Finance
</Button>
)}
{isCurrentApprovalOnRealization && (
<Button
variant='outline'
color='success'
onClick={completeExpenseClickHandler}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:done-all-rounded'
width={24}
height={24}
/>
Selesai
</Button>
)}
{showRejectButton && (
<Button
variant='outline'
color='error'
onClick={rejectClickHandler}
className='w-full:w-fit'
>
<Icon icon='material-symbols:close' width={24} height={24} />
Reject
</Button>
)}
{isExpenseCanBeRealized && (
<Button
variant='outline'
color='info'
href={`/expense/realization/?expenseId=${initialValues?.id}`}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:money-bag-rounded'
width={24}
height={24}
/>
Realisasi
</Button>
)}
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{showEditButton && (
<Button
type='button'
color='warning'
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
className='px-4 grow sm:grow-0'
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit
</Button>
)}
<Button
type='button'
color='error'
onClick={deleteExpenseClickHandler}
className='px-4 grow sm:grow-0'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
</div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
<table className='table table-sm table-zebra'>
<tbody>
<tr>
<th>Nomor PO</th>
<th>:</th>
<td>
{!initialValues?.po_number && '-'}
{initialValues?.po_number && (
<ExpensePDFPreviewButton expense={initialValues} />
)}
</td>
</tr>
<tr>
<th>Nomor Referensi</th>
<th>:</th>
<td>{initialValues?.reference_number}</td>
</tr>
<tr>
<th>Kategori</th>
<th>:</th>
<td>
{initialValues?.category === 'BOP'
? 'Biaya Operasional'
: 'Non Biaya Operasional'}
</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?.supplier.name}</td>
</tr>
<tr>
<th>Tanggal Transaksi</th>
<th>:</th>
<td>
{formatDate(initialValues?.expense_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?.grand_total ?? 0)}</td>
</tr>
<tr>
<th>Status Pencairan</th>
<th>:</th>
<td>
<RealizationStatusBadge
approval={initialValues?.latest_approval}
/>
</td>
</tr>
<tr>
<th>Status Biaya</th>
<th>:</th>
<td>
<ExpenseStatusBadge
approval={initialValues?.latest_approval}
/>
</td>
</tr>
<tr>
<th>Dokumen Pengajuan</th>
<th>:</th>
<td>
<div>
{!initialValues?.documents ||
(initialValues?.documents &&
initialValues?.documents.length === 0 &&
'-')}
{initialValues?.documents &&
initialValues?.documents.length > 0 && (
<ul className='list-disc'>
{initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => (
<li key={requestDocumentIdx}>
<Link
href={requestDocument.path}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{requestDocument.path}{' '}
<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='documents'
values={formik.values.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.documents &&
formik.values.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?.kandangs.map(
(kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price)
);
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.name}
</th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.pengajuans?.map(
(pengajuanItem, pengajuanIdx) => (
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>
{formatCurrency(pengajuanItem.total_price)}
</td>
<td className='w-xs'>
{pengajuanItem.note ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div>
</div>
<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,
}}
/>
<ConfirmationModal
ref={completeModal.ref}
type='success'
text='Apakah anda yakin ingin menyelesaikan biaya operasional ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isCompleteLoading,
onClick: confirmationModalCompleteClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
text='Apakah anda yakin ingin approve data biaya operasional 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 biaya operasional ini?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isRejectLoading,
onClick: confirmationModalRejectClickHandler,
}}
/>
</>
);
};
export default ExpenseRequestContent;
+152 -78
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
CellContext, CellContext,
@@ -31,13 +31,14 @@ import DateInput from '@/components/input/DateInput';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency } from '@/lib/helper'; import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { BaseApiResponse } from '@/types/api/api-general';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -53,18 +54,19 @@ const RowOptionsMenu = ({
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = const showEditButton =
props.row.original.approval.action !== 'REJECTED' && props.row.original.latest_approval.step_number !== 5 &&
props.row.original.approval.step_number !== 5 && (props.row.original.latest_approval.step_number === 1 ||
props.row.original.approval.action !== 'APPROVED'; props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3);
const showDeleteButton = showEditButton;
// TODO: apply RBAC // TODO: apply RBAC
const showApproveButton = showEditButton; const showRealizationButton =
const showRejectButton = showEditButton; props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 3;
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<Button <Button
href={`/expense/detail/?expenseId=${props.row.original.id}`} href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost' variant='ghost'
@@ -87,32 +89,22 @@ const RowOptionsMenu = ({
</Button> </Button>
)} )}
{/* TODO: apply RBAC */} {showRealizationButton && (
{showApproveButton && (
<Button <Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='success' color='info'
onClick={approveClickHandler} className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
className='justify-start text-sm'
> >
<Icon icon='material-symbols:check' width={24} height={24} /> <Icon
Approve icon='material-symbols:money-bag-rounded'
width={16}
height={16}
/>
Realisasi
</Button> </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 <Button
onClick={deleteClickHandler} onClick={deleteClickHandler}
variant='ghost' variant='ghost'
@@ -127,7 +119,7 @@ const RowOptionsMenu = ({
/> />
Delete Delete
</Button> </Button>
)} </div>
</RowOptionsMenuWrapper> </RowOptionsMenuWrapper>
); );
}; };
@@ -178,6 +170,7 @@ const ExpensesTable = () => {
undefined undefined
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
@@ -187,6 +180,57 @@ const ExpensesTable = () => {
parseInt(item) parseInt(item)
); );
const isAllSelectedRowLatestApprovalOnManager = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnManager =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 1;
return isCurrentApprovalOnManager;
});
}, [expenses, selectedRowIds]);
const isAllSelectedRowLatestApprovalOnFinance = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnFinance =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 2;
return isCurrentApprovalOnFinance;
});
}, [expenses, selectedRowIds]);
const isAllSelectedRowLatestApprovalOnRealization = useMemo(() => {
return selectedRowIds.every((rowId) => {
if (!isResponseSuccess(expenses)) return false;
const expenseItem = expenses.data.find((item) => item.id === rowId);
const isLatestApprovalRejected =
expenseItem?.latest_approval.action === 'REJECTED';
const isCurrentApprovalOnRealization =
!isLatestApprovalRejected &&
expenseItem?.latest_approval.step_number === 4;
return isCurrentApprovalOnRealization;
});
}, [expenses, selectedRowIds]);
const expensesColumns: ColumnDef<Expense>[] = [ const expensesColumns: ColumnDef<Expense>[] = [
{ {
id: 'select', id: 'select',
@@ -202,7 +246,8 @@ const ExpensesTable = () => {
), ),
cell: ({ row }) => { cell: ({ row }) => {
const isCheckboxDisabled = const isCheckboxDisabled =
!row.getCanSelect() || row.original.approval.action === 'REJECTED'; !row.getCanSelect() ||
row.original.latest_approval.action === 'REJECTED';
return ( return (
<div> <div>
@@ -218,61 +263,52 @@ const ExpensesTable = () => {
}, },
}, },
{ {
accessorKey: 'transaction_date', accessorKey: 'expense_date',
header: 'Tanggal Pengajuan', header: 'Tanggal Pengajuan',
cell: (props) =>
props.row.original.expense_date
? formatDate(props.row.original.expense_date, 'DD MMM YYYY')
: '-',
}, },
{ {
accessorKey: 'realization_date', accessorKey: 'realization_date',
header: 'Tanggal Realisasi', header: 'Tanggal Realisasi',
cell: (props) => props.getValue() ?? '-', cell: (props) =>
props.row.original.realization_date
? formatDate(props.row.original.realization_date, 'DD MMM YYYY')
: '-',
}, },
{ {
accessorKey: 'location', accessorKey: 'location',
header: 'Lokasi', header: 'Lokasi',
cell: (props) => props.row.original.location.name ?? '-', cell: (props) => props.row.original.location?.name ?? '-',
}, },
{ {
accessorFn: (row) => row.created_user.name ?? '-', accessorFn: (row) => row.created_user.name ?? '-',
header: 'Nama Pengaju', header: 'Nama Pengaju',
}, },
{ {
accessorFn: (row) => row.vendor.name ?? '-', accessorFn: (row) => row.supplier.name ?? '-',
header: 'Vendor', header: 'Vendor',
}, },
{ {
accessorKey: 'nominal', accessorKey: 'grand_total',
header: 'Nominal', header: 'Nominal',
cell: (props) => cell: (props) =>
props.row.original.nominal props.row.original.grand_total
? `Rp${formatCurrency(props.row.original.nominal)}` ? formatCurrency(props.row.original.grand_total)
: '-',
},
{
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', header: 'Status Pencairan',
cell: (props) => ( cell: (props) => (
<RealizationStatusBadge approval={props.row.original.approval} /> <RealizationStatusBadge approval={props.row.original.latest_approval} />
), ),
}, },
{ {
header: 'Status BOP', header: 'Status BOP',
cell: (props) => ( cell: (props) => (
<ExpenseStatusBadge approval={props.row.original.approval} /> <ExpenseStatusBadge approval={props.row.original.latest_approval} />
), ),
}, },
{ {
@@ -283,7 +319,7 @@ const ExpensesTable = () => {
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 - 3;
const approveClickHandler = () => { const approveClickHandler = () => {
setSelectedExpense(props.row.original); setSelectedExpense(props.row.original);
@@ -314,7 +350,7 @@ const ExpensesTable = () => {
return ( return (
<> <>
{currentPageSize > 2 && ( {currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}> <RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu <RowOptionsMenu
type='dropdown' type='dropdown'
@@ -326,10 +362,10 @@ const ExpensesTable = () => {
</RowDropdownOptions> </RowDropdownOptions>
)} )}
{currentPageSize <= 2 && ( {currentPageSize <= 3 && (
<RowCollapseOptions> <RowCollapseOptions>
<RowOptionsMenu <RowOptionsMenu
type='dropdown' type='collapse'
props={props} props={props}
approveClickHandler={approveClickHandler} approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler} rejectClickHandler={rejectClickHandler}
@@ -346,9 +382,20 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = ( const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row row
) => { ) => {
return row.original.approval.action !== 'REJECTED'; return (
row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 5
);
}; };
// const bulkApproveClickHandler = () => {
// approveModal.openModal();
// };
// const bulkRejectClickHandler = () => {
// rejectModal.openModal();
// };
const bulkApproveClickHandler = () => { const bulkApproveClickHandler = () => {
approveModal.openModal(); approveModal.openModal();
}; };
@@ -371,17 +418,26 @@ const ExpensesTable = () => {
const confirmationModalApproveClickHandler = async (notes: string) => { const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true); setIsApproveLoading(true);
const bulkApproveResponse = await ExpenseApi.bulkApprove( let bulkApproveResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnManager) {
bulkApproveResponse = await ExpenseApi.bulkApproveManager(
selectedRowIds, selectedRowIds,
notes notes
); );
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkApproveResponse = await ExpenseApi.bulkApproveFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkApproveResponse)) { if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses(); refreshExpenses();
approveModal.closeModal(); approveModal.closeModal();
toast.success( toast.success(
`Berhasil approve ${selectedRowIds.length} data transfer ke laying!` `Berhasil approve ${selectedRowIds.length} data biaya operasional!`
); );
setRowSelection({}); setRowSelection({});
@@ -389,7 +445,7 @@ const ExpensesTable = () => {
approveModal.closeModal(); approveModal.closeModal();
toast.error( toast.error(
`Gagal approve ${selectedRowIds.length} data transfer ke laying!` `Gagal approve ${selectedRowIds.length} data biaya operasional!`
); );
} }
@@ -399,24 +455,33 @@ const ExpensesTable = () => {
const confirmationModalRejectClickHandler = async (notes: string) => { const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true); setIsRejectLoading(true);
const bulkRejectResponse = await ExpenseApi.bulkReject( let bulkRejectResponse: BaseApiResponse<Expense> | undefined = undefined;
if (isAllSelectedRowLatestApprovalOnManager) {
bulkRejectResponse = await ExpenseApi.bulkRejectManager(
selectedRowIds, selectedRowIds,
notes notes
); );
} else if (isAllSelectedRowLatestApprovalOnFinance) {
bulkRejectResponse = await ExpenseApi.bulkRejectFinance(
selectedRowIds,
notes
);
}
if (isResponseSuccess(bulkRejectResponse)) { if (isResponseSuccess(bulkRejectResponse)) {
refreshExpenses(); refreshExpenses();
rejectModal.closeModal(); rejectModal.closeModal();
toast.success( toast.success(
`Berhasil reject ${selectedRowIds.length} data transfer ke laying!` `Berhasil reject ${selectedRowIds.length} data biaya operasional!`
); );
setRowSelection({}); setRowSelection({});
} else { } else {
rejectModal.closeModal(); rejectModal.closeModal();
toast.error( toast.error(
`Gagal reject ${selectedRowIds.length} data transfer ke laying!` `Gagal reject ${selectedRowIds.length} data biaya operasional!`
); );
} }
@@ -506,27 +571,36 @@ const ExpensesTable = () => {
{selectedRowIds.length > 0 && ( {selectedRowIds.length > 0 && (
<> <>
{/* TODO: apply RBAC */} <Button
variant='outline'
color='info'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnManager}
className='w-full sm:w-fit'
>
<Icon icon='lucide-lab:farm' width={24} height={24} />
Approve Manager
</Button>
<Button <Button
variant='outline' variant='outline'
color='success' color='success'
onClick={bulkApproveClickHandler} onClick={bulkApproveClickHandler}
disabled={selectedRowIds.length === 0} disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon <Icon icon='tdesign:money' width={24} height={24} />
icon='material-symbols:check' Approve Finance
width={24}
height={24}
/>
Approve
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
color='error' color='error'
onClick={bulkRejectClickHandler} onClick={bulkRejectClickHandler}
disabled={selectedRowIds.length === 0} disabled={
!isAllSelectedRowLatestApprovalOnManager &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon <Icon
@@ -666,7 +740,7 @@ const ExpensesTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={approveModal.ref} ref={approveModal.ref}
type='success' type='success'
text={`Apakah anda yakin ingin approve data biaya operasional ini (${selectedRowIds.length} data)?`} text='Apakah anda yakin ingin approve data biaya operasional ini?'
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -681,7 +755,7 @@ const ExpensesTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={rejectModal.ref} ref={rejectModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin reject data biaya operasional ini (${selectedRowIds.length} data)?`} text='Apakah anda yakin ingin reject data biaya operasional ini?'
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -0,0 +1,181 @@
import * as Yup from 'yup';
import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper';
type ExpenseRealizationFormSchemaType = {
category?: {
value: 'BOP' | 'NON-BOP';
label: 'BOP' | 'NON-BOP';
};
location?: {
value: number;
label: string;
};
realization_date?: string;
kandangs?: { id: number; name: string }[];
supplier?: {
value: number;
label: string;
};
existing_documents?: { name: string; url: string }[];
documents?: File[];
realizations: {
kandang_id: number;
cost_items: {
nonstock?: {
value: number;
label: string;
};
quantity?: number;
total_cost?: number;
notes?: string;
}[];
}[];
};
export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFormSchemaType> =
Yup.object({
category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
}).required('Kategori wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).required('Lokasi wajib diisi!'),
realization_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!'),
supplier: 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(),
})
),
documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
realizations: Yup.array()
.of(
Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
cost_items: Yup.array()
.of(
Yup.object({
nonstock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).required('Nonstock wajib diisi!'),
quantity: Yup.number().required('Total kuantitas wajib diisi!'),
total_cost: 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 UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema;
export const UploadRealizationDocumentsFormSchema = Yup.object({
realization_documents: Yup.array()
.of(Yup.mixed<File>().required())
.required(),
});
export type ExpenseRealizationFormValues = Yup.InferType<
typeof ExpenseRealizationFormSchema
>;
export type UploadRealizationDocumentsFormValues = Yup.InferType<
typeof UploadRealizationDocumentsFormSchema
>;
export const getExpenseRealizationFormInitialValues = (
initialValues?: Expense
): ExpenseRealizationFormValues => {
return {
category: initialValues?.category
? {
value: initialValues.category,
label: initialValues.category,
}
: undefined,
location: initialValues?.location
? {
value: initialValues.location.id,
label: initialValues.location.name,
}
: undefined,
realization_date: initialValues?.realization_date
? formatDate(initialValues?.realization_date, 'YYYY-MM-DD')
: undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id,
name: kandang.name,
})),
supplier: initialValues?.supplier
? {
value: initialValues.supplier.id,
label: initialValues.supplier.name,
}
: undefined,
existing_documents: initialValues?.realization_docs?.map((doc) => ({
name: doc.path,
url: doc.path,
})),
documents: [],
realizations: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => {
const costItemsInitialValue = kandangExpense.realisasi
? kandangExpense.realisasi.map((realisasiItem, realisasiIdx) => {
return {
nonstock: {
value: kandangExpense.pengajuans?.[realisasiIdx]
.id as number,
label: realisasiItem.nonstock.name,
},
quantity: realisasiItem.qty,
total_cost: realisasiItem.total_price,
notes: realisasiItem.note,
};
})
: kandangExpense.pengajuans
? kandangExpense.pengajuans.map((expenseItem) => ({
nonstock: {
value: expenseItem.id,
label: expenseItem.nonstock.name,
},
quantity: expenseItem.qty,
total_cost: expenseItem.total_price,
notes: expenseItem.note,
}))
: [];
return {
kandang_id: kandangExpense.kandang_id,
cost_items: costItemsInitialValue,
};
})
: [],
};
};
@@ -0,0 +1,410 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import DropFileInput from '@/components/input/DropFileInput';
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
import ExpenseRealizationKandangDetailExpense from '@/components/pages/expense/form/ExpenseRealizationKandangDetailExpense';
import {
CreateExpenseRealizationPayload,
Expense,
UpdateExpenseRealizationPayload,
} from '@/types/api/expense';
import {
ExpenseRealizationFormSchema,
ExpenseRealizationFormValues,
getExpenseRealizationFormInitialValues,
UpdateExpenseRealizationFormSchema,
} from '@/components/pages/expense/form/ExpenseRealizationForm.schema';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseError } from '@/lib/api-helper';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { cn } from '@/lib/helper';
interface ExpenseRealizationFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Expense;
}
const ExpenseRealizationForm = ({
type = 'add',
initialValues,
}: ExpenseRealizationFormProps) => {
const router = useRouter();
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
const createExpenseHandler = useCallback(
async (payload: CreateExpenseRealizationPayload) => {
const createExpenseRes = await ExpenseApi.createRealization(
initialValues?.id as number,
ExpenseApi.convertExpenseRealizationPayloadToFormData(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: UpdateExpenseRealizationPayload) => {
const updateExpenseRes = await ExpenseApi.updateRealization(
expenseId,
ExpenseApi.convertExpenseRealizationPayloadToFormData(payload)
);
if (updateExpenseRes?.status === 'error') {
setExpenseFormErrorMessage(updateExpenseRes.message);
return;
}
toast.success(updateExpenseRes?.message as string);
router.refresh();
router.push('/expense');
},
[router]
);
const formik = useFormik<ExpenseRealizationFormValues>({
initialValues: getExpenseRealizationFormInitialValues(initialValues),
validationSchema:
type === 'edit'
? UpdateExpenseRealizationFormSchema
: ExpenseRealizationFormSchema,
onSubmit: async (values) => {
setExpenseFormErrorMessage('');
const realizations: CreateExpenseRealizationPayload['realizations'] = [];
values.realizations.forEach((realization) => {
realization.cost_items.forEach((costItem) => {
const unitPrice =
parseFloat(String(costItem.total_cost)) /
parseFloat(String(costItem.quantity));
const realizationItem = {
expense_nonstock_id: costItem.nonstock?.value as number,
qty: parseFloat(String(costItem.quantity)) as number,
unit_price: unitPrice,
total_price: parseFloat(String(costItem.total_cost)) as number,
notes: costItem.notes ?? '',
};
realizations.push(realizationItem);
});
});
const expensePayload: CreateExpenseRealizationPayload = {
realization_date: values.realization_date as string,
documents: values.documents as File[],
realizations,
};
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('realizations', []);
};
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
const newRealizations = [...(formik.values.realizations ?? [])];
// add new realizations
kandangs.forEach((kandangItem) => {
const isKandangExistInRealization = newRealizations.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id
);
if (isKandangExistInRealization) return;
newRealizations.push({
kandang_id: kandangItem.id,
cost_items: [
{
nonstock: undefined,
quantity: undefined,
total_cost: undefined,
notes: '',
},
],
});
});
// prune realizations
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedRealizationsIdx: number[] = [];
newRealizations.forEach((realization, idx) => {
const isRealizationValid = kandangIds.has(realization.kandang_id);
if (!isRealizationValid) {
deletedRealizationsIdx.push(idx);
}
});
deletedRealizationsIdx.forEach((deletedRealizationIdx) => {
newRealizations.splice(deletedRealizationIdx, 1);
});
formik.setFieldValue('realizations', newRealizations);
};
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('vendor', true);
formik.setFieldValue('vendor', val);
};
const realizationDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('documents', true);
formik.setFieldValue('documents', val);
};
const realizationDocumentsDeleteHandler = (deletedFileIdx: number) => {
const newRequestDocuments = formik.values.documents;
newRequestDocuments?.splice(deletedFileIdx, 1);
formik.setFieldValue('documents', newRequestDocuments);
};
useEffect(() => {
formikSetValues(getExpenseRealizationFormInitialValues(initialValues));
}, [formikSetValues, getExpenseRealizationFormInitialValues, 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'>
Realisasi 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}
isDisabled
className={{ wrapper: 'col-span-12 sm:col-span-6' }}
/>
<DateInput
name='realization_date'
label='Tanggal Realisasi'
required
value={formik.values.realization_date}
onChange={formik.handleChange}
className={{
wrapper: 'col-span-12 sm:col-span-6',
}}
/>
<ExpenseKandangsTable
type='detail'
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.supplier}
onChange={vendorChangeHandler}
options={vendorOptions}
isLoading={isLoadingVendorOptions}
onInputChange={setVendorInputValue}
isDisabled
className={{ wrapper: 'col-span-12' }}
/>
<DropFileInput
label='Dokumen Realisasi'
name='documents'
values={formik.values.documents}
onChange={realizationDocumentsChangeHandler}
onDelete={realizationDocumentsDeleteHandler}
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>
)}
<ExpenseRealizationKandangDetailExpense
formik={formik}
className={{
wrapper: 'col-span-12',
}}
/>
</div>
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error w-full'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
{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>
</form>
</section>
);
};
export default ExpenseRealizationForm;
@@ -0,0 +1,224 @@
'use client';
import { FormikContextType } from 'formik';
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 { ExpenseRealizationFormValues } from '@/components/pages/expense/form/ExpenseRealizationForm.schema';
import { cn } from '@/lib/helper';
import { NonstockApi } from '@/services/api/master-data';
import { Nonstock } from '@/types/api/master-data/nonstock';
interface ExpenseRealizationKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRealizationFormValues>;
className?: {
wrapper?: string;
};
}
const ExpenseRealizationKandangDetailExpense: React.FC<
ExpenseRealizationKandangDetailExpenseProps
> = ({ type, formik, className }) => {
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const nonstockChangeHandler = (
kandangExpenseIdx: number,
costItemIdx: number,
val: OptionType | OptionType[] | null
) => {
formik.setFieldTouched(
`realizations[${kandangExpenseIdx}].cost_items[${costItemIdx}].nonstock`,
true
);
formik.setFieldValue(
`realizations[${kandangExpenseIdx}].cost_items[${costItemIdx}].nonstock`,
val
);
};
const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
) => {
return (
formik.touched.realizations?.[kandangExpenseIdx]?.cost_items?.[
expenseIdx
]?.[column] &&
Boolean(
formik.errors.realizations?.[kandangExpenseIdx] instanceof Object &&
formik.errors.realizations?.[kandangExpenseIdx].cost_items?.[
expenseIdx
] instanceof Object &&
formik.errors.realizations?.[kandangExpenseIdx].cost_items?.[
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 Realisasi Biaya Operasional
</h4>
</div>
<div className='w-full flex flex-col gap-6'>
{formik.values.realizations.length === 0 && (
<div>
<p className='text-sm text-gray-400 text-center'>
Pilih kandang terlebih dahulu!
</p>
</div>
)}
{formik.values.realizations.map((kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id
);
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>
</tr>
</thead>
<tbody>
{kandangExpense.cost_items.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' }}
isDisabled
/>
</td>
<td className='p-2'>
<NumberInput
required
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`}
placeholder='Masukkan Total Biaya'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].total_cost ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'total_cost',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
</span>
}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<TextInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].notes ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
</div>
)
);
})}
</div>
</Card>
);
};
export default ExpenseRealizationKandangDetailExpense;
@@ -3,27 +3,32 @@ import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
type ExpenseFormSchemaType = { type ExpenseFormSchemaType = {
category?: {
value: 'BOP' | 'NON-BOP';
label: 'BOP' | 'NON-BOP';
};
location?: { location?: {
value: number; value: number;
label: string; label: string;
}; };
transaction_date?: string; transaction_date?: string;
kandangs?: { id: number; name: string }[]; kandangs?: { id: number; name: string }[];
vendor?: { supplier?: {
value: number; value: number;
label: string; label: string;
}; };
existing_documents?: { name: string; url: string }[]; existing_documents?: { id: number; name: string; url: string }[];
request_documents?: File[]; deleted_documents?: number[];
kandangExpenses: { documents?: File[];
kandangId: number; cost_per_kandangs: {
expenses: { kandang_id: number;
cost_items: {
nonstock?: { nonstock?: {
value: number; value: number;
label: string; label: string;
}; };
totalQuantity?: number; quantity?: number;
totalExpense?: number; total_cost?: number;
notes?: string; notes?: string;
}[]; }[];
}[]; }[];
@@ -31,6 +36,11 @@ type ExpenseFormSchemaType = {
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> = export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
Yup.object({ Yup.object({
category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
}).required('Kategori wajib diisi!'),
location: Yup.object({ location: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -47,35 +57,36 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
.min(1, 'Kandang wajib dipilih!') .min(1, 'Kandang wajib dipilih!')
.required('Kandang wajib dipilih!'), .required('Kandang wajib dipilih!'),
vendor: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).required('Vendor wajib diisi!'), }).required('Vendor wajib diisi!'),
existing_documents: Yup.array().of( existing_documents: Yup.array().of(
Yup.object({ Yup.object({
id: Yup.number().required(),
name: Yup.string().required(), name: Yup.string().required(),
url: Yup.string().required(), url: Yup.string().required(),
}) })
), ),
request_documents: Yup.array().of(Yup.mixed<File>().required()).optional(), deleted_documents: Yup.array().of(Yup.number().required()).optional(),
kandangExpenses: Yup.array() documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
cost_per_kandangs: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandangId: Yup.number().min(1, 'Wajib memilih kandang!').required(), kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
expenses: Yup.array() cost_items: Yup.array()
.of( .of(
Yup.object({ Yup.object({
nonstock: Yup.object({ nonstock: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).required('Nonstock wajib diisi!'), }).required('Nonstock wajib diisi!'),
totalQuantity: Yup.number().required( quantity: Yup.number().required('Total kuantitas wajib diisi!'),
'Total kuantitas wajib diisi!' total_cost: Yup.number().required('Total biaya wajib diisi!'),
),
totalExpense: Yup.number().required('Total biaya wajib diisi!'),
notes: Yup.string(), notes: Yup.string(),
}) })
) )
@@ -90,7 +101,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema; export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema;
export const UploadRequestDocumentsFormSchema = Yup.object({ export const UploadRequestDocumentsFormSchema = Yup.object({
request_documents: Yup.array().of(Yup.mixed<File>().required()).required(), documents: Yup.array().of(Yup.mixed<File>().required()).required(),
}); });
export type ExpenseRequestFormValues = Yup.InferType< export type ExpenseRequestFormValues = Yup.InferType<
@@ -105,39 +116,52 @@ export const getExpenseFormInitialValues = (
initialValues?: Expense initialValues?: Expense
): ExpenseRequestFormValues => { ): ExpenseRequestFormValues => {
return { return {
category: initialValues?.category
? {
value: initialValues.category,
label: initialValues.category,
}
: undefined,
location: initialValues?.location location: initialValues?.location
? { ? {
value: initialValues.location.id, value: initialValues.location.id,
label: initialValues.location.name, label: initialValues.location.name,
} }
: undefined, : undefined,
transaction_date: initialValues?.transaction_date transaction_date: initialValues?.expense_date
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD') ? formatDate(initialValues.expense_date, 'YYYY-MM-DD')
: undefined, : undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({ kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.id, id: kandang.kandang_id,
name: kandang.name, name: kandang.name,
})), })),
vendor: initialValues?.vendor supplier: initialValues?.supplier
? { ? {
value: initialValues.vendor.id, value: initialValues.supplier.id,
label: initialValues.vendor.name, label: initialValues.supplier.name,
} }
: undefined, : undefined,
existing_documents: initialValues?.request_documents, existing_documents: initialValues?.documents?.map((doc) => ({
request_documents: [], id: doc.id,
kandangExpenses: initialValues?.kandang_expenses name: doc.path,
? initialValues.kandang_expenses.map((kandangExpense) => ({ url: doc.path,
kandangId: kandangExpense.kandang.id, })),
expenses: kandangExpense.expenses.map((expenseItem) => ({ deleted_documents: [],
documents: [],
cost_per_kandangs: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => ({
kandang_id: kandangExpense.kandang_id,
cost_items: kandangExpense.pengajuans
? kandangExpense.pengajuans.map((expenseItem) => ({
nonstock: { nonstock: {
value: expenseItem.nonstock.id, value: expenseItem.nonstock.id,
label: expenseItem.nonstock.name, label: expenseItem.nonstock.name,
}, },
totalQuantity: expenseItem.total_quantity, quantity: expenseItem.qty,
totalExpense: expenseItem.total_expense, total_cost: expenseItem.total_price,
notes: expenseItem.notes, notes: expenseItem.note,
})), }))
: [],
})) }))
: [], : [],
}; };
@@ -42,7 +42,6 @@ interface ExpenseFormProps {
initialValues?: Expense; initialValues?: Expense;
} }
// TODO: integrate this with real API
const ExpenseRequestForm = ({ const ExpenseRequestForm = ({
type = 'add', type = 'add',
initialValues, initialValues,
@@ -59,7 +58,7 @@ const ExpenseRequestForm = ({
const createExpenseHandler = useCallback( const createExpenseHandler = useCallback(
async (payload: CreateExpensePayload) => { async (payload: CreateExpensePayload) => {
const createExpenseRes = await ExpenseApi.create( const createExpenseRes = await ExpenseApi.create(
ExpenseApi.convertPayloadToFormData(payload) ExpenseApi.convertExpenseRequestPayloadToFormData(payload)
); );
if (isResponseError(createExpenseRes)) { if (isResponseError(createExpenseRes)) {
@@ -74,10 +73,15 @@ const ExpenseRequestForm = ({
); );
const updateExpenseHandler = useCallback( const updateExpenseHandler = useCallback(
async (expenseId: number, payload: UpdateExpensePayload) => { async (
expenseId: number,
payload: UpdateExpensePayload,
deletedDocumentIds: number[]
) => {
const updateExpenseRes = await ExpenseApi.update( const updateExpenseRes = await ExpenseApi.update(
expenseId, expenseId,
ExpenseApi.convertPayloadToFormData(payload) ExpenseApi.convertExpenseRequestUpdatePayloadToFormData(payload),
deletedDocumentIds
); );
if (updateExpenseRes?.status === 'error') { if (updateExpenseRes?.status === 'error') {
@@ -102,20 +106,17 @@ const ExpenseRequestForm = ({
setExpenseFormErrorMessage(''); setExpenseFormErrorMessage('');
const expensePayload: CreateExpensePayload = { const expensePayload: CreateExpensePayload = {
locationId: values.location?.value as number, category: formik.values.category?.value as 'BOP' | 'NON-BOP',
kandangIds: values.kandangs transaction_date: values?.transaction_date as string,
? values.kandangs.map((item) => item.id) supplier_id: values.supplier?.value as number,
: [], documents: values.documents as File[],
transaction_date: values.transaction_date as string, cost_per_kandangs: values.cost_per_kandangs.map((costPerKandang) => ({
vendorId: values.vendor?.value as number, kandang_id: costPerKandang.kandang_id,
request_documents: values.request_documents as File[], cost_items: costPerKandang.cost_items.map((costItem) => ({
kandang_expenses: values.kandangExpenses.map((kandangExpense) => ({ nonstock_id: costItem.nonstock?.value as number,
kandangId: kandangExpense.kandangId, quantity: parseFloat(String(costItem.quantity)) as number,
expenses: kandangExpense.expenses.map((expenseItem) => ({ total_cost: parseFloat(String(costItem.total_cost)) as number,
nonstockId: expenseItem.nonstock?.value as number, notes: costItem.notes ?? '',
total_quantity: expenseItem.totalQuantity as number,
total_expense: expenseItem.totalExpense as number,
notes: expenseItem.notes,
})), })),
})), })),
}; };
@@ -126,9 +127,28 @@ const ExpenseRequestForm = ({
break; break;
case 'edit': case 'edit':
const expenseUpdatePayload: UpdateExpensePayload = {
category: formik.values.category?.value as 'BOP' | 'NON-BOP',
transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number,
documents: values.documents as File[],
cost_per_kandang: values.cost_per_kandangs.map(
(costPerKandang) => ({
kandang_id: costPerKandang.kandang_id,
cost_items: costPerKandang.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number,
total_cost: parseFloat(String(costItem.total_cost)) as number,
notes: costItem.notes ?? '',
})),
})
),
};
await updateExpenseHandler( await updateExpenseHandler(
initialValues?.id as number, initialValues?.id as number,
expensePayload expenseUpdatePayload,
formik.values.deleted_documents ?? []
); );
break; break;
} }
@@ -145,72 +165,103 @@ const ExpenseRequestForm = ({
const { const {
setInputValue: setVendorInputValue, setInputValue: setVendorInputValue,
options: vendorOptions, options: supplierOptions,
isLoadingOptions: isLoadingVendorOptions, isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('category', true);
formik.setFieldValue('category', val);
};
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true); formik.setFieldTouched('location', true);
formik.setFieldValue('location', val); formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []); formik.setFieldValue('kandangs', []);
formik.setFieldValue('kandangExpenses', []); formik.setFieldValue('cost_per_kandangs', []);
}; };
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
formik.setFieldTouched('kandangs', true); formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs); formik.setFieldValue('kandangs', kandangs);
const newKandangExpenses = [...(formik.values.kandangExpenses ?? [])]; const newCostPerKandangs = [...(formik.values.cost_per_kandangs ?? [])];
// add new kandangExpenses // add new cost_per_kandangs
kandangs.forEach((kandangItem) => { kandangs.forEach((kandangItem) => {
const isKandangExistInKandangExpense = newKandangExpenses.find( const isKandangExistInCostPerKandangs = newCostPerKandangs.find(
(kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id (costPerKandangItem) => costPerKandangItem.kandang_id === kandangItem.id
); );
if (isKandangExistInKandangExpense) return; if (isKandangExistInCostPerKandangs) return;
newKandangExpenses.push({ newCostPerKandangs.push({
kandangId: kandangItem.id, kandang_id: kandangItem.id,
expenses: [ cost_items: [
{ {
nonstock: undefined, nonstock: undefined,
totalExpense: undefined, quantity: undefined,
totalQuantity: undefined, total_cost: undefined,
notes: '', notes: '',
}, },
], ],
}); });
}); });
// prune kandangExpenses // prune cost_per_kandangs
const kandangIds = new Set(kandangs.map((kandang) => kandang.id)); const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedKandangExpensesIdx: number[] = []; const deletedCostPerKandangsIdx: number[] = [];
newKandangExpenses.forEach((kandangExpense, idx) => { newCostPerKandangs.forEach((costPerKandang, idx) => {
const isKandangExpenseValid = kandangIds.has(kandangExpense.kandangId); const isCostPerKandangValid = kandangIds.has(costPerKandang.kandang_id);
if (!isKandangExpenseValid) { if (!isCostPerKandangValid) {
deletedKandangExpensesIdx.push(idx); deletedCostPerKandangsIdx.push(idx);
} }
}); });
deletedKandangExpensesIdx.forEach((deletedKandangExpenseIdx) => { deletedCostPerKandangsIdx.forEach((deletedCostPerKandangIdx) => {
newKandangExpenses.splice(deletedKandangExpenseIdx, 1); newCostPerKandangs.splice(deletedCostPerKandangIdx, 1);
}); });
formik.setFieldValue('kandangExpenses', newKandangExpenses); formik.setFieldValue('cost_per_kandangs', newCostPerKandangs);
}; };
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('vendor', true); formik.setFieldTouched('supplier', true);
formik.setFieldValue('vendor', val); formik.setFieldValue('supplier', val);
}; };
const requestDocumentsChangeHandler = (val: File[]) => { const requestDocumentsChangeHandler = (val: File[]) => {
formik.setFieldTouched('request_documents', true); formik.setFieldTouched('documents', true);
formik.setFieldValue('request_documents', val); formik.setFieldValue('documents', val);
};
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
const newRequestDocuments = formik.values.documents;
newRequestDocuments?.splice(deletedFileIdx, 1);
formik.setFieldValue('documents', newRequestDocuments);
};
const deleteDocumentClickHandler = (
deletedDocumentIdx: number,
deletedDocumentId: number
) => {
const newDeletedDocumentIds = [...(formik.values.deleted_documents ?? [])];
const newExistingDocuments = [
...(formik.values.existing_documents ?? []),
].filter((_, idx) => idx !== deletedDocumentIdx);
newDeletedDocumentIds.push(deletedDocumentId);
formik.setFieldTouched('deleted_documents', true);
formik.setFieldValue('deleted_documents', newDeletedDocumentIds);
formik.setFieldTouched('existing_documents', true);
formik.setFieldValue('existing_documents', newExistingDocuments);
}; };
const deleteExpenseClickHandler = () => { const deleteExpenseClickHandler = () => {
@@ -269,6 +320,25 @@ const ExpenseRequestForm = ({
className='w-full mt-8 flex flex-col gap-6' className='w-full mt-8 flex flex-col gap-6'
> >
<div className='grid grid-cols-12 gap-4'> <div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Kategori'
required
placeholder='Pilih Kategori'
value={formik.values.category}
onChange={categoryChangeHandler}
options={[
{
value: 'BOP',
label: 'BOP',
},
{
value: 'NON-BOP',
label: 'NON-BOP',
},
]}
className={{ wrapper: 'col-span-12 sm:col-span-4' }}
/>
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
required required
@@ -278,7 +348,7 @@ const ExpenseRequestForm = ({
options={locationOptions} options={locationOptions}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
className={{ wrapper: 'col-span-12 sm:col-span-6' }} className={{ wrapper: 'col-span-12 sm:col-span-4' }}
/> />
<DateInput <DateInput
@@ -288,7 +358,7 @@ const ExpenseRequestForm = ({
value={formik.values.transaction_date} value={formik.values.transaction_date}
onChange={formik.handleChange} onChange={formik.handleChange}
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6', wrapper: 'col-span-12 sm:col-span-4',
}} }}
/> />
@@ -306,9 +376,9 @@ const ExpenseRequestForm = ({
label='Vendor' label='Vendor'
required required
placeholder='Pilih Vendor' placeholder='Pilih Vendor'
value={formik.values.vendor} value={formik.values.supplier}
onChange={vendorChangeHandler} onChange={supplierChangeHandler}
options={vendorOptions} options={supplierOptions}
isLoading={isLoadingVendorOptions} isLoading={isLoadingVendorOptions}
onInputChange={setVendorInputValue} onInputChange={setVendorInputValue}
className={{ wrapper: 'col-span-12' }} className={{ wrapper: 'col-span-12' }}
@@ -316,9 +386,10 @@ const ExpenseRequestForm = ({
<DropFileInput <DropFileInput
label='Dokumen Pengajuan' label='Dokumen Pengajuan'
name='request_documents' name='documents'
values={formik.values.request_documents} values={formik.values.documents}
onChange={requestDocumentsChangeHandler} onChange={requestDocumentsChangeHandler}
onDelete={requestDocumentsDeleteHandler}
accept={{ accept={{
...ACCEPTED_FILE_TYPE.PDF, ...ACCEPTED_FILE_TYPE.PDF,
...ACCEPTED_FILE_TYPE.IMAGE, ...ACCEPTED_FILE_TYPE.IMAGE,
@@ -336,6 +407,7 @@ const ExpenseRequestForm = ({
{formik.values.existing_documents.map( {formik.values.existing_documents.map(
(existingDocument, existingDocumentIdx) => ( (existingDocument, existingDocumentIdx) => (
<li key={existingDocumentIdx}> <li key={existingDocumentIdx}>
<div className='w-full flex flex-wrap justify-between'>
<Link <Link
href={existingDocument.url} href={existingDocument.url}
target='_blank' target='_blank'
@@ -350,6 +422,26 @@ const ExpenseRequestForm = ({
className='inline' className='inline'
/> />
</Link> </Link>
<Button
type='button'
variant='ghost'
color='error'
onClick={() => {
deleteDocumentClickHandler(
existingDocumentIdx,
existingDocument.id
);
}}
className='p-1 rounded-full text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='fluent:delete-12-regular'
width={20}
height={20}
/>
</Button>
</div>
</li> </li>
) )
)} )}
@@ -402,6 +494,17 @@ const ExpenseRequestForm = ({
</div> </div>
)} )}
{expenseFormErrorMessage && (
<div role='alert' className='alert alert-error w-full'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{expenseFormErrorMessage}</span>
</div>
)}
{type !== 'detail' && ( {type !== 'detail' && (
<div <div
className={cn('flex flex-row justify-end gap-2', { className={cn('flex flex-row justify-end gap-2', {
@@ -424,17 +527,6 @@ const ExpenseRequestForm = ({
</div> </div>
)} )}
</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> </form>
</section> </section>
@@ -41,28 +41,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
) => { ) => {
formik.setFieldTouched( formik.setFieldTouched(
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`, `cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true true
); );
formik.setFieldValue( formik.setFieldValue(
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`, `cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val val
); );
}; };
const addExpenseItemHandler = (kandangExpenseIdx: number) => { const addExpenseItemHandler = (kandangExpenseIdx: number) => {
const newExpensesValue = [ const newExpensesValue = [
...formik.values.kandangExpenses[kandangExpenseIdx].expenses, ...formik.values.cost_per_kandangs[kandangExpenseIdx].cost_items,
{ {
nonstock: undefined, nonstock: undefined,
totalExpense: undefined, total_cost: undefined,
totalQuantity: undefined, quantity: undefined,
notes: '', notes: '',
}, },
]; ];
formik.setFieldValue( formik.setFieldValue(
`kandangExpenses[${kandangExpenseIdx}].expenses`, `cost_per_kandangs[${kandangExpenseIdx}].cost_items`,
newExpensesValue newExpensesValue
); );
}; };
@@ -71,27 +71,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
const path = `kandangExpenses[${kandangExpenseIdx}].expenses`; const path = `cost_per_kandangs[${kandangExpenseIdx}].cost_items`;
// trims values, errors, and touched at expenseIdx // trims values, errors, and touched at expenseIdx
removeArrayItemAndSync(formik, path, expenseIdx); removeArrayItemAndSync(formik, path, expenseIdx);
}; };
const isExpenseRepeaterInputError = ( const isExpenseRepeaterInputError = (
column: 'nonstock' | 'totalQuantity' | 'totalExpense' | 'notes', column: 'nonstock' | 'quantity' | 'total_cost' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
return ( return (
formik.touched.kandangExpenses?.[kandangExpenseIdx]?.expenses?.[ formik.touched.cost_per_kandangs?.[kandangExpenseIdx]?.cost_items?.[
expenseIdx expenseIdx
]?.[column] && ]?.[column] &&
Boolean( Boolean(
formik.errors.kandangExpenses?.[kandangExpenseIdx] instanceof Object && formik.errors.cost_per_kandangs?.[kandangExpenseIdx] instanceof
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[ Object &&
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
] instanceof Object && ] instanceof Object &&
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[ formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
]?.[column] ]?.[column]
) )
@@ -112,7 +113,8 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div> </div>
<div className='w-full flex flex-col gap-6'> <div className='w-full flex flex-col gap-6'>
{formik.values.kandangExpenses.length === 0 && ( {(formik.values.cost_per_kandangs.length === 0 ||
!formik.values.supplier?.value) && (
<div> <div>
<p className='text-sm text-gray-400 text-center'> <p className='text-sm text-gray-400 text-center'>
Pilih kandang terlebih dahulu! Pilih kandang terlebih dahulu!
@@ -120,10 +122,12 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div> </div>
)} )}
{formik.values.kandangExpenses.map( {formik.values.cost_per_kandangs.length > 0 &&
formik.values.supplier?.value &&
formik.values.cost_per_kandangs.map(
(kandangExpense, kandangExpenseIdx) => { (kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find( const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandangId (kandang) => kandang.id === kandangExpense.kandang_id
); );
return ( return (
@@ -150,7 +154,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</thead> </thead>
<tbody> <tbody>
{kandangExpense.expenses.map( {kandangExpense.cost_items.map(
(expenseItem, expenseIdx) => ( (expenseItem, expenseIdx) => (
<tr key={`expense-${expenseIdx}`}> <tr key={`expense-${expenseIdx}`}>
<td className='p-2'> <td className='p-2'>
@@ -174,17 +178,17 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
required required
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalQuantity`} name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas' placeholder='Masukkan Total Kuantitas'
value={ value={
formik.values.kandangExpenses[ formik.values.cost_per_kandangs[
kandangExpenseIdx kandangExpenseIdx
].expenses[expenseIdx].totalQuantity ?? '' ].cost_items[expenseIdx].quantity ?? ''
} }
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError( isError={isExpenseRepeaterInputError(
'totalQuantity', 'quantity',
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
@@ -194,17 +198,18 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalExpense`} name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`}
placeholder='Masukkan Total Biaya' placeholder='Masukkan Total Biaya'
value={ value={
formik.values.kandangExpenses[ formik.values.cost_per_kandangs[
kandangExpenseIdx kandangExpenseIdx
].expenses[expenseIdx].totalExpense ?? '' ].cost_items[expenseIdx].total_cost ??
''
} }
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError( isError={isExpenseRepeaterInputError(
'totalExpense', 'total_cost',
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
@@ -219,12 +224,12 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<TextInput <TextInput
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].notes`} name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan' placeholder='Tuliskan catatan'
value={ value={
formik.values.kandangExpenses[ formik.values.cost_per_kandangs[
kandangExpenseIdx kandangExpenseIdx
].expenses[expenseIdx].notes ?? '' ].cost_items[expenseIdx].notes ?? ''
} }
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -0,0 +1,651 @@
'use client';
import {
Document,
Image,
Link,
Page,
StyleSheet,
Text,
View,
} from '@react-pdf/renderer';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
interface ExpensePDFProps {
expense?: Expense;
}
const ExpensePDFStyle = StyleSheet.create({
page: {
paddingTop: 24,
paddingBottom: 64,
paddingHorizontal: 32,
},
companyInfoHeader: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
companyLogo: {
width: 64,
height: 'auto',
},
companyInfoHeaderDate: {
paddingTop: 8,
fontSize: 12,
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 400,
marginBottom: 10,
},
title: {
marginTop: 16,
fontSize: 16,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 32,
position: 'absolute',
fontSize: 10,
bottom: 30,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
// wrapper
generalInfoTable: {
width: '100%',
marginTop: 8,
borderWidth: 1,
borderColor: '#000000',
borderBottomWidth: 0,
fontSize: 12,
},
generalInfoTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
},
// columns
generalInfoTableColLabel: {
width: '30%',
paddingVertical: 6,
paddingHorizontal: 8,
},
generalInfoTableColSeparator: {
width: '3%',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 6,
},
generalInfoTableColValue: {
width: '67%',
paddingVertical: 6,
paddingHorizontal: 8,
},
generalInfoTableLabelText: {
fontWeight: 'bold',
},
generalInfoTableValueText: {},
// expense detail table
expenseDetailContainer: {
width: '100%',
marginTop: 12,
},
expenseDetailTitle: {
fontSize: 14,
lineHeight: '150%',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
textAlign: 'center',
},
kandangExpenseContainer: {
width: '100%',
marginTop: 8,
},
kandangExpenseTitle: {
fontSize: 14,
lineHeight: '150%',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
textAlign: 'center',
},
kandangExpenseTable: {
width: '100%',
marginTop: 8,
borderWidth: 1,
borderColor: '#000000',
borderBottomWidth: 0,
fontSize: 12,
},
kandangExpenseTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
},
kandangExpenseTableColLabel: {
width: '20%',
paddingVertical: 6,
paddingHorizontal: 8,
},
kandangExpenseTableColLabelBorderRight: {
borderRight: '1px solid #000000',
},
kandangExpenseTableColNonstock: {
width: '20%',
},
kandangExpenseTableColNote: {
width: '40%',
},
kandangExpenseHeaderLabelText: {
fontWeight: 'bold',
},
kandangExpenseLabelText: {
fontSize: 10,
},
kandangExpenseTableFooterColTotalExpenseCaption: {
width: '40%',
paddingVertical: 6,
paddingHorizontal: 8,
textAlign: 'right',
},
kandangExpenseTableFooterColTotalExpenseValue: {
width: '60%',
paddingVertical: 6,
paddingHorizontal: 8,
},
// utils
doubleDivider: {
width: '100%',
height: 6,
borderTop: '2px solid black',
borderBottom: '2px solid black',
},
});
const ExpensePDF = ({ expense }: ExpensePDFProps) => {
const isLatestApprovalRejected =
expense?.latest_approval?.action === 'REJECTED';
const isExpenseRealized =
expense?.latest_approval?.step_number &&
expense?.latest_approval.step_number >= 4;
const realizationStatus = isExpenseRealized
? 'Sudah Realisasi'
: 'Belum Realisasi';
const rows = [
{ label: 'Nomor PO', value: expense?.po_number },
{ label: 'Nomor Referensi', value: expense?.reference_number },
{
label: 'Kategori',
value:
expense?.category === 'BOP'
? 'Biaya Operasional'
: expense?.category === 'NON-BOP'
? 'Non Biaya Operasional'
: '',
},
{ label: 'Lokasi', value: expense?.location.name },
{
label: 'Kandang',
value: expense?.kandangs.map((item) => item.name).join(', '),
},
{ label: 'Vendor', value: expense?.supplier.name },
{
label: 'Tanggal Transaksi',
value: formatDate(expense?.expense_date, 'DD MMMM YYYY'),
},
{
label: 'Tanggal Realisasi',
value: expense?.realization_date
? formatDate(expense?.realization_date, 'DD MMMM YYYY')
: '-',
},
{ label: 'Nama Pengaju', value: expense?.created_user.name },
{
label: 'Nominal Biaya',
value: formatCurrency(expense?.grand_total ?? 0),
},
{
label: 'Nominal Pengajuan',
value: formatCurrency(expense?.total_pengajuan ?? 0),
},
{
label: 'Nominal Realisasi',
value: expense?.total_realisasi
? formatCurrency(expense?.total_realisasi ?? 0)
: '-',
},
{ label: 'Status Pencairan', value: realizationStatus },
{
label: 'Status Biaya',
value: isLatestApprovalRejected
? 'Ditolak'
: expense?.latest_approval?.step_name,
},
];
return (
<Document>
<Page style={ExpensePDFStyle.page}>
<View>
<View style={ExpensePDFStyle.companyInfoHeader}>
<Image
style={ExpensePDFStyle.companyLogo}
src='/assets/img/lti-logo.png'
/>
<Text style={ExpensePDFStyle.companyInfoHeaderDate}>
{formatDate(Date.now(), 'DD MMMM YYYY')}
</Text>
</View>
<View>
<Text style={ExpensePDFStyle.companyName}>
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={ExpensePDFStyle.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={ExpensePDFStyle.doubleDivider} />
</View>
</View>
<Text style={ExpensePDFStyle.title}>
Laporan{' '}
{expense?.category === 'BOP'
? 'Biaya Operasional'
: 'Non-Biaya Operasional'}{' '}
{expense?.po_number}
</Text>
{/* General info table */}
<View style={ExpensePDFStyle.generalInfoTable}>
{rows.map((row) => (
<View style={ExpensePDFStyle.generalInfoTableRow} key={row.label}>
<View style={ExpensePDFStyle.generalInfoTableColLabel}>
<Text style={ExpensePDFStyle.generalInfoTableLabelText}>
{row.label}
</Text>
</View>
<View style={ExpensePDFStyle.generalInfoTableColSeparator}>
<Text>:</Text>
</View>
<View style={ExpensePDFStyle.generalInfoTableColValue}>
<Text style={ExpensePDFStyle.generalInfoTableValueText}>
{row.value}
</Text>
</View>
</View>
))}
</View>
{/* Detail expense request */}
<View
minPresenceAhead={80}
style={ExpensePDFStyle.expenseDetailContainer}
>
<Text style={ExpensePDFStyle.expenseDetailTitle}>
Rincian Pengajuan Biaya Operasional
</Text>
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.total_price)
);
return (
<View
key={kandangExpenseIdx}
style={ExpensePDFStyle.kandangExpenseContainer}
>
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.name}
</Text>
<View style={ExpensePDFStyle.kandangExpenseTable}>
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Nonstock
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Kuantitas
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Catatan
</Text>
</View>
</View>
{kandangExpense.pengajuans?.map((pengajuan, pengajuanIdx) => (
<View
key={pengajuanIdx}
style={ExpensePDFStyle.kandangExpenseTableRow}
>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.nonstock.name}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatNumber(pengajuan.qty)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(pengajuan.total_price)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.note}
</Text>
</View>
</View>
))}
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya Keseluruhan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
{formatCurrency(expenseRequestTotal)}
</Text>
</View>
</View>
</View>
</View>
);
})}
</View>
{/* Detail expense realization */}
<View
minPresenceAhead={80}
style={ExpensePDFStyle.expenseDetailContainer}
>
<Text style={ExpensePDFStyle.expenseDetailTitle}>
Rincian Realisasi Biaya Operasional
</Text>
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.total_price)
);
return (
<View
key={kandangExpenseIdx}
style={ExpensePDFStyle.kandangExpenseContainer}
>
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.name}
</Text>
<View style={ExpensePDFStyle.kandangExpenseTable}>
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Nonstock
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Kuantitas
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Catatan
</Text>
</View>
</View>
{kandangExpense.realisasi?.map((realisasi, realisasiIdx) => (
<View
key={realisasiIdx}
style={ExpensePDFStyle.kandangExpenseTableRow}
>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.nonstock.name}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatNumber(realisasi.qty)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(realisasi.total_price)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.note}
</Text>
</View>
</View>
))}
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya Keseluruhan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
{formatCurrency(expenseRealizationTotal)}
</Text>
</View>
</View>
</View>
</View>
);
})}
</View>
<View style={ExpensePDFStyle.footer} fixed>
<Link
src={`${process.env.NEXT_PUBLIC_LTI_URL}expense/detail?expenseId=${expense?.id}`}
>
{expense?.po_number}
</Link>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page>
</Document>
);
};
export default ExpensePDF;
@@ -0,0 +1,53 @@
'use client';
import { pdf } from '@react-pdf/renderer';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import ExpensePDF from '@/components/pages/expense/pdf/ExpensePDF';
import { Expense } from '@/types/api/expense';
interface ExpensePDFPreviewButtonProps {
expense?: Expense;
}
const ExpensePDFPreviewButton = ({ expense }: ExpensePDFPreviewButtonProps) => {
const openPdf = async () => {
const expensePdfBlob = await pdf(<ExpensePDF expense={expense} />).toBlob();
const expensePdfUrl = URL.createObjectURL(expensePdfBlob);
window.open(expensePdfUrl, '_blank');
};
const downloadPdf = async () => {
const blob = await pdf(<ExpensePDF expense={expense} />).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${expense?.po_number}.pdf`;
link.click();
URL.revokeObjectURL(url);
};
return (
<div className='w-fit flex flex-col'>
<Button onClick={downloadPdf} className='text-xs'>
<Icon icon='bx:file' width={16} height={16} />
{expense?.po_number}
</Button>
<Button
onClick={openPdf}
variant='link'
className='p-0 mt-1 text-xs justify-start'
>
Lihat Dokumen
</Button>
</div>
);
};
export default ExpensePDFPreviewButton;
@@ -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,44 @@
'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 { 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 { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
import Button from '@/components/Button';
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 +48,24 @@ const MovementTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit' }, search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
}); });
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 { data: movements, isLoading } = useSWR(
const {
data: movements,
isLoading,
mutate: refreshMovements,
} = 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 +74,7 @@ const MovementTable = () => {
setPage(1); setPage(1);
}; };
const confirmationModalDeleteClickHandler = async () => { const movementColumns: ColumnDef<Movement>[] = [
setIsDeleteLoading(true);
try {
await MovementApi.delete(selectedMovement?.id as number);
refreshMovements();
deleteModal.closeModal();
} finally {
setIsDeleteLoading(false);
}
};
return (
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2 mb-4'>
<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>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={[
{ {
header: '#', header: '#',
cell: (props) => cell: (props) =>
@@ -118,9 +98,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 +113,78 @@ 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='flex justify-end gap-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 +209,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({
@@ -122,7 +171,17 @@ export const MovementFormSchema = Yup.object({
}).nullable(), }).nullable(),
destination_warehouse_id: Yup.number() destination_warehouse_id: Yup.number()
.required('Gudang tujuan wajib diisi!') .required('Gudang tujuan wajib diisi!')
.typeError('Gudang tujuan wajib diisi!'), .typeError('Gudang tujuan wajib diisi!')
.test(
'different-warehouse',
'Gudang tujuan tidak boleh sama dengan gudang asal!',
function (value) {
const { source_warehouse_id } = this.parent;
return (
!value || !source_warehouse_id || value !== source_warehouse_id
);
}
),
products: Yup.array() products: Yup.array()
.of(ProductObjectSchema) .of(ProductObjectSchema)
.min(1, 'Minimal harus ada 1 produk!') .min(1, 'Minimal harus ada 1 produk!')
@@ -131,9 +190,7 @@ export const MovementFormSchema = Yup.object({
.of(DeliveryObjectSchema) .of(DeliveryObjectSchema)
.min(1, 'Minimal harus ada 1 pengiriman!') .min(1, 'Minimal harus ada 1 pengiriman!')
.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>;
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,
};
};
@@ -2,34 +2,41 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal'; import Modal, { 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 { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar'; import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatVechicleNumber } from '@/lib/helper'; import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { MarketingApi } from '@/services/api/marketing/marketing'; import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing'; import { BaseSalesOrder, Marketing } from '@/types/api/marketing/marketing';
import { Customer } from '@/types/api/master-data/customer';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext } from '@tanstack/react-table'; import { CellContext, Row } from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
props, props,
deleteClickHandler, deleteClickHandler,
deliveryClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; type: 'dropdown' | 'collapse';
props: CellContext<Marketing, unknown>; props: CellContext<Marketing, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
deliveryClickHandler?: () => void;
}) => { }) => {
return ( return (
<div <div
@@ -44,7 +51,7 @@ const RowsOptionsMenu = ({
> >
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<Button <Button
href={`/marketing/sales-orders/detail/?salesOrderId=${props.row.original.id}`} href={`/marketing/detail?marketingId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='primary' color='primary'
className='justify-start text-sm' className='justify-start text-sm'
@@ -52,8 +59,31 @@ const RowsOptionsMenu = ({
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='mdi:eye-outline' width={16} height={16} />
Detail Detail
</Button> </Button>
{props.row.original.latest_approval.step_number != 1 && (
<Button <Button
href={`/marketing/sales-orders/detail/edit/?salesOrderId=${props.row.original.id}`} 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' variant='ghost'
color='warning' color='warning'
className='justify-start text-sm' className='justify-start text-sm'
@@ -61,6 +91,7 @@ const RowsOptionsMenu = ({
<Icon icon='mdi:pencil-outline' width={16} height={16} /> <Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit Edit
</Button> </Button>
)}
<Button <Button
onClick={deleteClickHandler} onClick={deleteClickHandler}
variant='ghost' variant='ghost'
@@ -75,19 +106,18 @@ const RowsOptionsMenu = ({
); );
}; };
const SalesOrderTable = () => { const MarketingTable = () => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [approveAction, setApproveAction] = useState< const [approveAction, setApproveAction] = useState<'APPROVED' | 'REJECTED'>(
'approve' | 'reject' | null 'APPROVED'
>(null); );
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null); const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).filter(
(id) => rowSelection[id] const router = useRouter();
);
const { const {
data: marketing, data: marketing,
@@ -98,6 +128,7 @@ const SalesOrderTable = () => {
const deleteModal = useModal(); const deleteModal = useModal();
const confirmationModal = useModal(); const confirmationModal = useModal();
const productsModal = useModal(); const productsModal = useModal();
const deliveryModal = useModal();
const searchChangeHandler = useCallback( const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -116,12 +147,12 @@ const SalesOrderTable = () => {
); );
const approveClickHandler = () => { const approveClickHandler = () => {
setApproveAction('approve'); setApproveAction('APPROVED');
confirmationModal.openModal(); confirmationModal.openModal();
}; };
const rejectClickHandler = () => { const rejectClickHandler = () => {
setApproveAction('reject'); setApproveAction('REJECTED');
confirmationModal.openModal(); confirmationModal.openModal();
}; };
@@ -130,6 +161,91 @@ const SalesOrderTable = () => {
productsModal.openModal(); 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 { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -144,13 +260,18 @@ const SalesOrderTable = () => {
}, },
}); });
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
};
return ( return (
<> <>
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<TableToolbar <TableToolbar
addButton={{ addButton={{
href: '/marketing/sales-orders/add', href: '/marketing/add/sales-orders',
label: 'Tambah Sales Order', label: 'Tambah Sales Order',
}} }}
search={{ search={{
@@ -159,17 +280,12 @@ const SalesOrderTable = () => {
placeholder: 'Cari Sales Order', placeholder: 'Cari Sales Order',
}} }}
/> />
<TableRowSizeSelector
value={pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Button <Button
color='success' color='success'
onClick={approveClickHandler} onClick={approveClickHandler}
className='justify-start text-sm' className='justify-start text-sm'
disabled={!selectedRowIds.length} disabled={disableApprove}
> >
<Icon icon='material-symbols:check' width={24} height={24} /> <Icon icon='material-symbols:check' width={24} height={24} />
Approve Approve
@@ -179,41 +295,93 @@ const SalesOrderTable = () => {
color='error' color='error'
onClick={rejectClickHandler} onClick={rejectClickHandler}
className='justify-start text-sm' className='justify-start text-sm'
disabled={!selectedRowIds.length} disabled={disableReject}
> >
<Icon icon='material-symbols:close' width={24} height={24} /> <Icon icon='material-symbols:close' width={24} height={24} />
Reject Reject
</Button> </Button>
</div> </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> </div>
<Table <Table
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
data={isResponseSuccess(marketing) ? marketing.data : []} data={allData}
columns={[ columns={[
{ {
id: 'select', id: 'select',
header: ({ table }) => ( 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'> <div className='w-full flex flex-row justify-center'>
<CheckboxInput <CheckboxInput
name='allRow' name='allRow'
checked={table.getIsAllRowsSelected()} checked={allSelected}
indeterminate={table.getIsSomeRowsSelected()} indeterminate={someSelected}
onChange={table.getToggleAllRowsSelectedHandler()} onChange={toggleSelectableRows}
disabled={selectableRows.length === 0}
/> />
</div> </div>
), );
cell: ({ row }) => ( },
cell: ({ row }) => {
const canSelect = getRowCanSelect(row);
return (
<div> <div>
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={row.getIsSelected()} checked={row.getIsSelected()}
disabled={!row.getCanSelect()} disabled={!canSelect}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
/> />
</div> </div>
), );
},
}, },
{ {
accessorKey: 'so_number', accessorKey: 'so_number',
@@ -222,9 +390,12 @@ const SalesOrderTable = () => {
{ {
accessorKey: 'so_date', accessorKey: 'so_date',
header: 'Tanggal', header: 'Tanggal',
cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
},
}, },
{ {
accessorKey: 'approval.step_name', accessorKey: 'latest_approval.step_name',
header: 'Status', header: 'Status',
}, },
{ {
@@ -232,15 +403,25 @@ const SalesOrderTable = () => {
header: 'Customer', header: 'Customer',
}, },
{ {
accessorKey: 'grand_total', accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total', 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', accessorKey: 'marketing_products.length',
header: 'Product Details', header: 'Product Details',
cell: (props) => { cell: (props) => {
if (props?.row?.original?.marketing_products?.length) { if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.marketing_products?.length > 1) { if (props?.row?.original?.sales_order?.length > 1) {
return ( return (
<Button <Button
variant='link' variant='link'
@@ -250,12 +431,11 @@ const SalesOrderTable = () => {
productsClickHandler(props?.row?.original); productsClickHandler(props?.row?.original);
}} }}
> >
Lihat {props?.row?.original?.marketing_products?.length}{' '} Lihat {props?.row?.original?.sales_order?.length} Produk
Produk
</Button> </Button>
); );
} else { } else {
const product = props?.row?.original?.marketing_products[0]; const product = props?.row?.original?.sales_order[0];
return <>{product?.product_warehouse?.product?.name}</>; return <>{product?.product_warehouse?.product?.name}</>;
} }
} }
@@ -274,7 +454,15 @@ const SalesOrderTable = () => {
const isLast2Rows = const isLast2Rows =
currentRowRelativeIndex > currentPageSize - 2; currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {}; const deleteClickHandler = () => {
setSelectedItem(props.row.original);
deleteModal.openModal();
};
const deliveryClickHandler = () => {
setSelectedItem(props.row.original);
deliveryModal.openModal();
};
return ( return (
<> <>
@@ -284,6 +472,7 @@ const SalesOrderTable = () => {
type='dropdown' type='dropdown'
props={props} props={props}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/> />
</RowDropdownOptions> </RowDropdownOptions>
)} )}
@@ -294,6 +483,7 @@ const SalesOrderTable = () => {
type='collapse' type='collapse'
props={props} props={props}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/> />
</RowCollapseOptions> </RowCollapseOptions>
)} )}
@@ -330,16 +520,45 @@ const SalesOrderTable = () => {
}} }}
/> />
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmationModal.ref} ref={confirmationModal.ref}
type={approveAction === 'approve' ? 'success' : 'error'} type={approveAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction} data penjualan (${selectedRowIds.length} data)?`} 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={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: approveAction === 'approve' ? 'success' : 'error', color: 'success',
onClick: confirmationModalDeliveryClickHandler,
}} }}
/> />
@@ -361,10 +580,10 @@ const SalesOrderTable = () => {
<Icon icon='mdi:close' width={16} height={16} /> <Icon icon='mdi:close' width={16} height={16} />
</Button> </Button>
</div> </div>
<Table<MarketingProduct> <Table<BaseSalesOrder>
data={ data={
isResponseSuccess(marketing) && selectedItem isResponseSuccess(marketing) && selectedItem
? (selectedItem?.marketing_products ?? []) ? (selectedItem?.sales_order ?? [])
: [] : []
} }
columns={[ columns={[
@@ -403,4 +622,4 @@ const SalesOrderTable = () => {
</> </>
); );
}; };
export default SalesOrderTable; 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;
@@ -1,7 +1,7 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
type MarketingProductSchemaType = { type SalesOrderProductSchemaType = {
vehicle_number: string | undefined; id?: number | undefined;
kandang_id?: number; kandang_id?: number;
kandang?: { kandang?: {
value: number; value: number;
@@ -15,15 +15,15 @@ type MarketingProductSchemaType = {
unit_price: string | number | undefined; unit_price: string | number | undefined;
total_weight: string | number | undefined; total_weight: string | number | undefined;
qty: string | number | undefined; qty: string | number | undefined;
uom: string | undefined | null;
avg_weight: string | number | undefined; avg_weight: string | number | undefined;
total_price: string | number | undefined; total_price: string | number | undefined;
delivery_date?: string | undefined | null; vehicle_number?: string | undefined;
}; };
export const MarketingProductSchema: Yup.ObjectSchema<MarketingProductSchemaType> = export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
Yup.object({ Yup.object({
vehicle_number: Yup.string().required('No. Polisi wajib diisi!'), id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({ kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -42,21 +42,19 @@ export const MarketingProductSchema: Yup.ObjectSchema<MarketingProductSchemaType
.min(1, 'Harga Satuan wajib diisi!') .min(1, 'Harga Satuan wajib diisi!')
.required('Harga Satuan wajib diisi!'), .required('Harga Satuan wajib diisi!'),
total_weight: Yup.number() total_weight: Yup.number()
.min(1, 'Total Bobot wajib diisi!') .min(0, 'Total Bobot wajib diisi!')
.required('Total Bobot wajib diisi!'), .required('Total Bobot wajib diisi!'),
qty: Yup.number() qty: Yup.number()
.min(1, 'Kuantitas wajib diisi!') .min(1, 'Kuantitas wajib diisi!')
.required('Kuantitas wajib diisi!'), .required('Kuantitas wajib diisi!'),
uom: Yup.string().nullable(),
avg_weight: Yup.number() avg_weight: Yup.number()
.min(1, 'Avg. Bobot wajib diisi!') .min(0, 'Avg. Bobot wajib diisi!')
.required('Avg. Bobot wajib diisi!'), .required('Avg. Bobot wajib diisi!'),
total_price: Yup.number() total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!') .min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'), .required('Total Penjualan wajib diisi!'),
delivery_date: Yup.string().required().nullable(),
}); });
export type MarketingProductFormValues = Yup.InferType< export type SalesOrderProductFormValues = Yup.InferType<
typeof MarketingProductSchema typeof SalesOrderProductSchema
>; >;
@@ -1,17 +1,11 @@
'use client'; 'use client';
import TextInput from '@/components/input/TextInput';
import {
CreateMarketingPayload,
CreateMarketingProductPayload,
MarketingProduct,
} from '@/types/api/marketing/marketing';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { import {
MarketingProductFormValues, SalesOrderProductFormValues,
MarketingProductSchema, SalesOrderProductSchema,
} from './MarketingProduct.schema'; } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, use, useEffect, useRef, useState } from 'react'; import { RefObject, useMemo, useState } from 'react';
import SelectInput, { import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
@@ -23,37 +17,52 @@ import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import PatternInput from '@/components/input/PatternInput';
import { formatVechicleNumber } from '@/lib/helper'; import { formatVechicleNumber } from '@/lib/helper';
import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert';
const MarketingProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
data, exisitingValues,
modalRef,
onSubmitForm, onSubmitForm,
}: { }: {
initialValues?: MarketingProduct; initialValues?: SalesOrderProductFormValues;
data: MarketingProduct[]; exisitingValues?: SalesOrderProductFormValues[];
modalRef?: RefObject<HTMLDialogElement | null>; modalRef?: RefObject<HTMLDialogElement | null>;
onSubmitForm?: ( onSubmitForm?: (value: SalesOrderProductFormValues) => Promise<void>;
tableValues: CreateMarketingProductPayload,
fieldValues: MarketingProductFormValues
) => Promise<void>;
}) => { }) => {
// State
const [selectedOptionsKandang, setSelectedOptionsKandang] =
useState<OptionType | null>(null);
const [selectedOptionsWarehouse, setSelectedOptionsWarehouse] = useState<
OptionType | null | undefined
>(undefined);
const [formErrorMessage, setFormErrorMessage] = useState(''); 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,
});
// Options Data
const { const {
options: kandangSourceOptions, options: kandangSourceOptions,
rawData: kandangSourceRawData,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingKandangSourceOptions,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name'); } = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const { const {
options: warehouseSourceOptions, options: warehouseSourceOptions,
rawData: warehouseSourceRawData, rawData: warehouseSourceRawData,
@@ -64,109 +73,44 @@ const MarketingProductForm = ({
'product.name', 'product.name',
'search', 'search',
{ {
warehouse_id: selectedOptionsKandang?.value?.toString() ?? '', warehouse_id: formik.values.kandang_id?.toString() ?? '',
} }
); );
// Handler 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) => { const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedOptionsKandang(val as OptionType);
formik.setFieldValue('kandang', val as OptionType); formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value); formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('qty', null); formik.setFieldValue('qty', null);
warehouseChangeHandler(null);
}; };
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedOptionsWarehouse(val as OptionType);
formik.setFieldValue('product_warehouse', val as OptionType); formik.setFieldValue('product_warehouse', val as OptionType);
formik.setFieldValue('product_warehouse_id', (val as OptionType)?.value); const newId = (val as OptionType)?.value;
if (isResponseSuccess(warehouseSourceRawData)) { formik.setFieldValue('product_warehouse_id', newId);
if (isResponseSuccess(warehouseSourceRawData) && newId) {
const productWarehouse = warehouseSourceRawData?.data.find( const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === (val as OptionType)?.value (item: ProductWarehouse) => item.id === newId
); );
if (selectedOptionsWarehouse?.value !== null) {
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
formik.setFieldValue('qty', null); formik.setFieldValue('qty', null);
} }
}
}; };
// Formik
const formik = useFormik<MarketingProductFormValues>({
enableReinitialize: true,
initialValues: {
vehicle_number:
initialValues?.marketing_delivery_products?.vehicle_number || undefined,
kandang_id: initialValues?.product_warehouse.warehouse.id || undefined,
kandang: {
value: initialValues?.product_warehouse.warehouse.id as number,
label: initialValues?.product_warehouse.warehouse.name as string,
},
product_warehouse: {
value: initialValues?.product_warehouse.product.id as number,
label: initialValues?.product_warehouse.product.name as string,
},
product_warehouse_id:
initialValues?.product_warehouse.product.id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
uom: initialValues?.product_warehouse?.product?.uom?.name || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
delivery_date:
initialValues?.marketing_delivery_products?.delivery_date ||
new Date().toDateString() ||
undefined,
},
validationSchema: MarketingProductSchema,
onSubmit: async (values) => {
setFormErrorMessage('');
if (
isResponseSuccess(kandangSourceRawData) &&
isResponseSuccess(warehouseSourceRawData)
) {
const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === values.product_warehouse_id
);
const kandang = kandangSourceRawData?.data.find(
(item: Kandang) => item.id === values.kandang_id
);
const marketingProduct: CreateMarketingProductPayload = {
id: initialValues?.id || undefined,
vehicle_number: formatVechicleNumber(values.vehicle_number as string),
kandang_id: values.kandang_id as number,
kandang: kandang,
product_warehouse_id: values.product_warehouse_id as number,
product_warehouse: productWarehouse,
unit_price: values.unit_price as number,
total_weight: values.total_weight as number,
qty: values.qty as number,
uom: values.uom as string,
avg_weight: values.avg_weight as number,
total_price: values.total_price as number,
delivery_date: values.delivery_date as string,
};
onSubmitForm?.(marketingProduct, values);
handleResetForm();
}
},
});
const { setValues: formikSetValues } = formik;
useEffect(() => {
formikSetValues(formik.initialValues);
}, [formikSetValues, formik.initialValues]);
const handleResetForm = () => { const handleResetForm = () => {
setSelectedOptionsKandang(null);
setSelectedOptionsWarehouse(null);
setFormErrorMessage(''); setFormErrorMessage('');
formik.resetForm({ formik.resetForm({
values: { values: {
@@ -178,20 +122,19 @@ const MarketingProductForm = ({
unit_price: '', unit_price: '',
total_weight: '', total_weight: '',
qty: '', qty: '',
uom: '',
avg_weight: '', avg_weight: '',
total_price: '', total_price: '',
delivery_date: new Date().toDateString(),
}, },
}); });
}; };
const handleBlurField = (field: string) => { const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } = const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values; formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && field === 'unit_price') { if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue( formik.setFieldValue(
'total_price', 'total_price',
(qty as number) * (unit_price as number) (qty as number) * (unit_price as number)
@@ -205,7 +148,7 @@ const MarketingProductForm = ({
} }
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') { if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && field === 'avg_weight') { if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue( formik.setFieldValue(
'total_weight', 'total_weight',
(qty as number) * (avg_weight as number) (qty as number) * (avg_weight as number)
@@ -223,9 +166,23 @@ const MarketingProductForm = ({
<> <>
<form <form
className='size-full' className='size-full'
onSubmit={formik.handleSubmit} onSubmit={(e) => {
e.preventDefault();
handleBlurField(currentInput);
formik.handleSubmit(e);
}}
onReset={handleResetForm} 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'> <div className='grid grid-cols-2 gap-4 z-200'>
<PatternInput <PatternInput
name='vehicle_number' name='vehicle_number'
@@ -250,10 +207,9 @@ const MarketingProductForm = ({
label='Kandang' label='Kandang'
options={kandangSourceOptions} options={kandangSourceOptions}
isLoading={isLoadingKandangSourceOptions} isLoading={isLoadingKandangSourceOptions}
value={selectedOptionsKandang} value={formik.values.kandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
isClearable isClearable
menuPortalTarget={modalRef?.current}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -263,14 +219,19 @@ const MarketingProductForm = ({
<SelectInput <SelectInput
required required
label='Produk' label='Produk'
options={warehouseSourceOptions} options={productOptionsFiltered}
isLoading={isLoadingWarehouseSourceOptions} isLoading={isLoadingWarehouseSourceOptions}
value={selectedOptionsWarehouse} value={formik.values.product_warehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
isClearable isClearable
menuPortalTarget={modalRef?.current} placeholder={
placeholder='Pilih Kandang Terlebih Dahulu' formik.values.kandang_id
isDisabled={!selectedOptionsKandang?.value} ? productOptionsFiltered.length == 0
? 'Tidak ada produk yang tersedia'
: 'Pilih produk'
: 'Pilih Kandang Terlebih Dahulu'
}
isDisabled={!formik.values.kandang_id}
isError={ isError={
formik.touched.product_warehouse_id && formik.touched.product_warehouse_id &&
Boolean(formik.errors.product_warehouse_id) Boolean(formik.errors.product_warehouse_id)
@@ -282,7 +243,10 @@ const MarketingProductForm = ({
label='Kuantitas' label='Kuantitas'
name='qty' name='qty'
value={formik.values.qty} value={formik.values.qty}
onChange={formik.handleChange} onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('qty')} onBlur={() => handleBlurField('qty')}
isError={formik.touched.qty && Boolean(formik.errors.qty)} isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
@@ -293,7 +257,10 @@ const MarketingProductForm = ({
label='Avg. Bobot (Kg)' label='Avg. Bobot (Kg)'
name='avg_weight' name='avg_weight'
value={formik.values.avg_weight} value={formik.values.avg_weight}
onChange={formik.handleChange} onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')} onBlur={() => handleBlurField('avg_weight')}
isError={ isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight) formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
@@ -306,7 +273,10 @@ const MarketingProductForm = ({
label='Harga Satuan (Rp)' label='Harga Satuan (Rp)'
name='unit_price' name='unit_price'
value={formik.values.unit_price} value={formik.values.unit_price}
onChange={formik.handleChange} onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('unit_price')} onBlur={() => handleBlurField('unit_price')}
isError={ isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price) formik.touched.unit_price && Boolean(formik.errors.unit_price)
@@ -319,7 +289,10 @@ const MarketingProductForm = ({
label='Total Bobot (Kg)' label='Total Bobot (Kg)'
name='total_weight' name='total_weight'
value={formik.values.total_weight} value={formik.values.total_weight}
onChange={formik.handleChange} onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_weight')} onBlur={() => handleBlurField('total_weight')}
isError={ isError={
formik.touched.total_weight && Boolean(formik.errors.total_weight) formik.touched.total_weight && Boolean(formik.errors.total_weight)
@@ -332,7 +305,10 @@ const MarketingProductForm = ({
label='Total Penjualan (Rp)' label='Total Penjualan (Rp)'
name='total_price' name='total_price'
value={formik.values.total_price} value={formik.values.total_price}
onChange={formik.handleChange} onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('total_price')} onBlur={() => handleBlurField('total_price')}
isError={ isError={
formik.touched.total_price && Boolean(formik.errors.total_price) formik.touched.total_price && Boolean(formik.errors.total_price)
@@ -358,4 +334,4 @@ const MarketingProductForm = ({
); );
}; };
export default MarketingProductForm; 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;
@@ -1,308 +0,0 @@
'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 Table from '@/components/Table';
import {
cn,
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useState } from 'react';
import toast from 'react-hot-toast';
const SalesOrderDetail = ({
initialValues,
refreshValues,
}: {
initialValues?: Marketing;
refreshValues?: () => void;
}) => {
const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>(
'approve'
);
const [isLoading, setIsLoading] = useState(false);
const deleteModal = useModal();
const confirmationModal = useModal();
const deliveryModal = useModal();
const approveClickHandler = () => {
setApprovalAction('approve');
confirmationModal.openModal();
};
const rejectClickHandler = () => {
setApprovalAction('reject');
confirmationModal.openModal();
};
const deliveryClickHandler = () => {
deliveryModal.openModal();
};
const deleteClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.delete(initialValues?.id as number);
setIsLoading(false);
deleteModal.closeModal();
toast.success('Successfully deleted Sales Order!');
refreshValues?.();
};
const confirmationModalApproveClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.singleApproval(
// initialValues?.id as number,
// approvalAction
// );
setIsLoading(false);
confirmationModal.closeModal();
toast.success('Successfully approved Sales Order!');
refreshValues?.();
};
const confirmationModalDeliveryClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.delivery(initialValues?.id as number);
setIsLoading(false);
deliveryModal.closeModal();
toast.success('Successfully delivered Sales Order!');
refreshValues?.();
};
return (
<>
<div className='flex flex-col w-full gap-4'>
<FormHeader
title='Detail Sales Order'
backUrl='/marketing/sales-orders'
/>
<div className='flex-row flex gap-3'>
{initialValues?.approval?.step_number != 3 && (
<>
<Button
color='success'
onClick={approveClickHandler}
disabled={initialValues?.approval?.step_number != 1}
>
<Icon icon='mdi:check' width={24} height={24} />
Approve
</Button>
<Button
color='error'
onClick={rejectClickHandler}
disabled={initialValues?.approval?.step_number != 2}
>
<Icon icon='mdi:close' width={24} height={24} />
Reject
</Button>
</>
)}
{initialValues?.approval?.step_number == 2 && (
<Button color='success' onClick={deliveryClickHandler}>
<Icon icon='mdi:check' width={24} height={24} />
Delivery Order
</Button>
)}
</div>
<Card
title='Informasi Sales Order'
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?.approval?.step_name}</td>
</tr>
<tr>
<td className='font-semibold'>Tanggal Penjualan</td>
<td>:</td>
<td>{initialValues?.so_date}</td>
</tr>
<tr>
<td className='font-semibold'>Total Penjualan</td>
<td>:</td>
<td>
{formatCurrency(initialValues?.grand_total as number)}
</td>
</tr>
<tr>
<td className='font-semibold'>Catatan</td>
<td>:</td>
<td>{initialValues?.notes ?? '-'}</td>
</tr>
</tbody>
</table>
</div>
</Card>
{initialValues?.marketing_products && (
<Card
title='Daftar Produk'
className={{
wrapper: 'w-full bg-white',
}}
>
<Table<MarketingProduct>
data={initialValues?.marketing_products}
columns={[
{
header: 'No. Polisi',
accessorFn(row) {
return formatVechicleNumber(
row.marketing_delivery_products?.vehicle_number as string
);
},
},
{
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?.marketing_products &&
initialValues?.marketing_products?.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'>
<Button
color='warning'
type='button'
href={`/marketing/sales-orders/detail/edit?salesOrderId=${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,
}}
/>
<ConfirmationModal
ref={confirmationModal.ref}
type={approvalAction === 'approve' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approvalAction} data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction === 'approve' ? 'success' : 'error',
isLoading: isLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModal
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 SalesOrderDetail;
@@ -1,38 +0,0 @@
import * as Yup from 'yup';
import { MarketingProduct } from '@/types/api/marketing/marketing';
import {
MarketingProductFormValues,
MarketingProductSchema,
} from './repeater/MarketingProduct.schema';
type MarketingSchema = {
customer_id: number | undefined;
customer:
| {
value: number;
label: string;
}
| undefined
| null;
so_date: string | undefined;
notes: string | undefined;
marketing_products: MarketingProductFormValues[];
};
export const MarketingSchema: Yup.ObjectSchema<MarketingSchema> = Yup.object({
customer_id: Yup.number().required('Customer 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!'),
marketing_products: Yup.array()
.of(MarketingProductSchema)
.min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'),
});
export const UpdateMarketingSchema = MarketingSchema;
export type MarketingFormValues = Yup.InferType<typeof MarketingSchema>;
@@ -1,514 +0,0 @@
'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 * as TanStack from '@tanstack/react-table';
import Table from '@/components/Table'; // Keep this import
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import {
CreateMarketingPayload,
CreateMarketingProductPayload,
Marketing,
MarketingProduct,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import MarketingProductForm from './repeater/MarketingProductForm';
import CheckboxInput from '@/components/input/CheckboxInput';
import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { useFormik } from 'formik';
import { MarketingFormValues, MarketingSchema } from './SalesForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { MarketingProductFormValues } from './repeater/MarketingProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
const SalesForm = ({
formType = 'add',
initialValues,
}: {
formType?: 'add' | 'edit';
initialValues?: Marketing;
}) => {
const router = useRouter();
const addProductModal = useModal();
const deleteModal = useModal();
const [isLoading, setIsLoading] = useState(false);
const [selectedMarketingProduct, setSelectedMarketingProduct] =
useState<MarketingProduct | null>(null);
const [rawMarketingProducts, setRawMarketingProducts] = useState<
MarketingProduct[]
>(initialValues?.marketing_products || []);
const [selectedCustomer, setSelectedCustomer] = useState<OptionType | null>(
initialValues?.customer
? { value: initialValues.customer.id, label: initialValues.customer.name }
: null
);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
);
const [grandTotal, setGrandTotal] = useState<number>(
initialValues?.grand_total ?? 0
);
const marketingProducts = useMemo(
() => rawMarketingProducts,
[rawMarketingProducts]
);
const {
options: customerOptions,
rawData: customerRawData,
isLoadingOptions: isLoadingCustomerOptions,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const handleAddProduct = useCallback(() => {
addProductModal.openModal();
}, [addProductModal]);
const handleDeleteProduct = useCallback((id: number) => {
setRawMarketingProducts((prev) => prev.filter((p) => p.id !== id));
}, []);
const handleBulkDeleteProduct = () => {
setRawMarketingProducts((prev) =>
prev.filter((product) => !selectedRowIds.includes(product.id))
);
};
const handleDelete = () => {
deleteModal.openModal();
};
const handleAddSubmitProduct = useCallback(
async (
tableValue: CreateMarketingProductPayload,
fieldValues: MarketingProductFormValues
) => {
const newMarketingProduct: MarketingProduct = {
id: rawMarketingProducts.length + 1,
product_warehouse: tableValue.product_warehouse!,
unit_price: tableValue.unit_price as number,
total_weight: tableValue.total_weight as number,
qty: tableValue.qty as number,
avg_weight: tableValue.avg_weight as number,
total_price: tableValue.total_price as number,
marketing_delivery_products: {
id: rawMarketingProducts.length + 1,
vehicle_number: tableValue.vehicle_number as string,
delivery_date: tableValue.delivery_date as string,
unit_price: tableValue.unit_price as number,
total_weight: tableValue.total_weight as number,
qty: tableValue.qty as number,
avg_weight: tableValue.avg_weight as number,
total_price: tableValue.total_price as number,
},
};
setRawMarketingProducts((prev) => [...prev, newMarketingProduct]);
formik.setValues({
...formik.values,
marketing_products: [...formik.values.marketing_products, fieldValues],
});
setGrandTotal((prev) => prev + (tableValue.total_price as number));
addProductModal.closeModal();
},
[rawMarketingProducts.length, addProductModal]
);
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
setSelectedCustomer(val as OptionType);
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[selectedCustomer, setSelectedCustomer]
);
const createMarketingHandler = async (values: CreateMarketingPayload) => {
console.log(values);
const createMarketingRes = await MarketingApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
console.log(createMarketingRes);
}
if (isResponseError(createMarketingRes)) {
console.log(createMarketingRes);
}
toast.success('Successfully created Sales Order!');
router.push('/marketing/sales-orders');
};
const updateMarketingHandler = async (values: CreateMarketingPayload) => {
console.log(values);
const createMarketingRes = await MarketingApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(createMarketingRes)) {
console.log(createMarketingRes);
}
if (isResponseError(createMarketingRes)) {
console.log(createMarketingRes);
}
toast.success('Successfully updated Sales Order!');
router.push('/marketing/sales-orders');
};
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
}
if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
}
toast.success('Successfully deleted Sales Order!');
setIsLoading(false);
deleteModal.closeModal();
router.push('/marketing/sales-orders');
};
const MarketingProductToFieldValues = (
product: MarketingProduct
): MarketingProductFormValues => {
return {
vehicle_number: product.marketing_delivery_products?.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.product.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.product.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
uom: product.product_warehouse?.product?.uom?.name,
avg_weight: product.avg_weight,
total_price: product.total_price,
delivery_date: product.marketing_delivery_products?.delivery_date,
};
};
const formik = useFormik<MarketingFormValues>({
enableReinitialize: true,
initialValues: {
so_date: initialValues?.so_date || undefined,
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
customer: {
value: initialValues?.customer?.id as number,
label: initialValues?.customer?.name as string,
},
marketing_products:
initialValues?.marketing_products?.map((product) =>
MarketingProductToFieldValues(product)
) ?? [],
},
validationSchema: MarketingSchema,
onSubmit: async (values) => {
const payload = {
customer_id: values.customer_id as number,
date: values.so_date as string,
notes: values.notes as string,
marketing_products: values.marketing_products,
} as CreateMarketingPayload;
switch (formType) {
case 'add':
createMarketingHandler(payload);
break;
case 'edit':
updateMarketingHandler(payload);
break;
default:
break;
}
},
});
const { setValues: formikSetValues } = formik;
useEffect(() => {
formikSetValues(formik.initialValues);
}, [formikSetValues, formik.initialValues]);
const columns = useMemo(
() => [
{
id: 'select',
header: ({ table }: { table: TanStack.Table<MarketingProduct> }) => (
<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<MarketingProduct> }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
accessorFn: (row: MarketingProduct) =>
row.marketing_delivery_products?.vehicle_number,
header: 'No. Polisi',
},
{
accessorFn: (row: MarketingProduct) =>
row.product_warehouse.warehouse.name,
header: 'Kandang',
},
{
accessorFn: (row: MarketingProduct) =>
row.product_warehouse.product.name,
header: 'Produk',
},
{
accessorFn: (row: MarketingProduct) => formatCurrency(row.unit_price),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.total_weight),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.qty),
header: 'Kuantitas',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.avg_weight),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: MarketingProduct) => formatCurrency(row.total_price),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (props: TanStack.CellContext<MarketingProduct, unknown>) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<Button
color='error'
className='p-1'
onClick={() => handleDeleteProduct(props.row.original.id)}
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</div>
),
},
],
[handleDeleteProduct] // dependensi tunggal
);
return (
<>
<form
className='flex flex-col gap-4'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
<FormHeader
title={`${formType === 'add' ? 'Tambah' : 'Edit'} Sales Order`}
backUrl='/marketing/sales-orders'
/>
<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={selectedCustomer}
onChange={handleChangeCustomer}
isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id)
}
errorMessage={formik.errors.customer_id}
isClearable
placeholder='Pilih Pelanggan'
/>
<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'
/>
</div>
</Card>
<Card
title='Daftar Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
<Table<MarketingProduct>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={marketingProducts}
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-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-end',
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>
}
/>
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={handleAddProduct}
>
<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={handleBulkDeleteProduct}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Produk
</Button>
)}
</div>
</Card>
<div className='grid grid-cols-2 gap-3'>
<TextArea
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}
/>
<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>
<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}
>
Submit
</Button>
</div>
</form>
{formType == 'edit' && (
<div className='flex flex-row justify-start'>
<Button type='button' color='error' onClick={handleDelete}>
<Icon icon='mdi:trash' width={24} height={24} />
Hapus
</Button>
</div>
)}
<Modal
ref={addProductModal.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={addProductModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<MarketingProductForm
onSubmitForm={handleAddSubmitProduct}
modalRef={addProductModal.ref}
data={rawMarketingProducts}
initialValues={selectedMarketingProduct ?? undefined}
/>
</div>
</div>
</Modal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: deleteMarketingHandler,
}}
/>
</>
);
};
export default SalesForm;
@@ -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()
@@ -20,7 +42,7 @@ export const KandangFormSchema = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
}); });
export const UpdateKandangFormSchema = KandangFormSchema; export const UpdateKandangFormSchema = KandangFormSchema;
@@ -82,7 +82,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
capacity: initialValues?.capacity ?? 0, capacity: initialValues?.capacity,
picId: initialValues?.pic?.id ?? 0, picId: initialValues?.pic?.id ?? 0,
pic: initialValues?.pic pic: initialValues?.pic
? { ? {
@@ -102,9 +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!,
capacity: values.capacity, capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
pic_id: values.picId, pic_id: values.picId!,
}; };
switch (type) { switch (type) {
@@ -256,7 +256,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
required required
name='capacity' name='capacity'
label='Kapasitas' label='Kapasitas'
value={formik.values.capacity ?? undefined} placeholder='Masukan kapasitas kandang'
value={formik.values.capacity}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={ isError={
@@ -1,11 +1,19 @@
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!'),
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string()
}); .required('Nama wajib diisi!')
.max(50, 'Nama kategori produk melebihi 50 karakter!'),
});
export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema; export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
@@ -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,53 +1,86 @@
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!'),
}); });
export const UpdateProductFormSchema = ProductFormSchema; export const UpdateProductFormSchema = ProductFormSchema;
@@ -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] : [];
@@ -20,7 +20,6 @@ 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 {
@@ -45,7 +45,7 @@ const ChickinFormKandang = ({
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<FormHeader <FormHeader
title='Chick In DOC' title={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`} backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`}
/> />
@@ -3,6 +3,7 @@ import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -31,12 +32,13 @@ const ChickinLogsView = ({
confirmModal.openModal(); confirmModal.openModal();
}; };
const confirmationModalApproveClickHandler = async () => { const confirmationModalApproveClickHandler = async (notes?: string) => {
setChickinErrorMessage(''); setChickinErrorMessage('');
setIsApproveLoading(true); setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.singleApproval( const approveChickinRes = await ChickinApi.singleApproval(
initialValues?.id as number, initialValues?.id as number,
'APPROVED' 'APPROVED',
notes
); );
if (isResponseSuccess(approveChickinRes)) { if (isResponseSuccess(approveChickinRes)) {
toast.success(approveChickinRes?.message as string); toast.success(approveChickinRes?.message as string);
@@ -151,7 +153,7 @@ const ChickinLogsView = ({
</div> </div>
)} )}
</Card> </Card>
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type='success' type='success'
text={`Apakah anda yakin ingin approve data Chickin yang Pending?`} text={`Apakah anda yakin ingin approve data Chickin yang Pending?`}
@@ -161,7 +163,9 @@ const ChickinLogsView = ({
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'success', color: 'success',
onClick: confirmationModalApproveClickHandler, onClick: (notes) => {
confirmationModalApproveClickHandler(notes);
},
isLoading: isApproveLoading, isLoading: isApproveLoading,
}} }}
/> />
@@ -6,10 +6,8 @@ import {
ChickinFormValues, ChickinFormValues,
ChickinRequestFormValues, ChickinRequestFormValues,
ChickinSchema, ChickinSchema,
} from '../ChickinForm.schema'; } from '@/components/pages/production/chickin/form/ChickinForm.schema';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -24,7 +22,6 @@ import Alert from '@/components/Alert';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
const ChickinFormView = ({ const ChickinFormView = ({
formType = 'add',
initialValues, initialValues,
afterSubmit, afterSubmit,
}: { }: {
@@ -122,7 +119,7 @@ const ChickinFormView = ({
return ( return (
<form <form
className='flex flex-col gap-4' className='flex flex-col gap-4'
onReset={(e) => { onReset={() => {
handleReset(); handleReset();
}} }}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
@@ -6,6 +6,7 @@ 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';
@@ -144,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);
@@ -226,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!');
@@ -271,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}
@@ -279,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'
@@ -558,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',
}} }}
@@ -570,17 +591,19 @@ const ProjectFlockTable = () => {
}} }}
/> />
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type='success' type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin approve data Project Flock ini (${selectedRowIds.length} data)?`} text={`Apakah anda yakin ingin ${approvalAction == 'APPROVED' ? 'approve' : 'reject'} data Project Flock ini (${selectedRowIds.length} data)?`}
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,
}} }}
/> />
@@ -14,13 +14,13 @@ import { cn } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production'; import { ProjectFlockKandangApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { FormHeader } from '@/components/helper/form/FormHeader';
const ProjectFlockChickinDetail = ({ const ProjectFlockChickinDetail = ({
projectFlockId, projectFlockId,
@@ -42,10 +42,7 @@ const ProjectFlockChickinDetail = ({
const [projectFlock, setProjectFlock] = useState<ProjectFlock>(); const [projectFlock, setProjectFlock] = useState<ProjectFlock>();
// Fetch Data // Fetch Data
const { const { data: listProjectFlockKandang } = useSWR(
data: listProjectFlockKandang,
isLoading: isLoadingListProjectFlockKandang,
} = useSWR(
`${ProjectFlockKandangApi.basePath}?${new URLSearchParams({ `${ProjectFlockKandangApi.basePath}?${new URLSearchParams({
search: searchProjectFlock, search: searchProjectFlock,
project_flock_id: project_flock_id:
@@ -104,6 +101,10 @@ const ProjectFlockChickinDetail = ({
}, [projectFlockId, listProjectFlock]); }, [projectFlockId, listProjectFlock]);
return ( 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='flex flex-col gap-4 w-full my-4'>
<div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'> <div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'>
<SelectInput <SelectInput
@@ -118,7 +119,7 @@ const ProjectFlockChickinDetail = ({
value={ value={
projectFlock projectFlock
? { ? {
label: `${projectFlock?.flock?.name}`, label: `${projectFlock?.flock_name}`,
value: projectFlock?.id, value: projectFlock?.id,
} }
: null : null
@@ -175,7 +176,7 @@ const ProjectFlockChickinDetail = ({
}, },
{ {
header: 'Nama Flock', header: 'Nama Flock',
accessorKey: 'flock.name', accessorKey: 'flock_name',
}, },
{ {
header: 'Kategori', header: 'Kategori',
@@ -209,10 +210,6 @@ const ProjectFlockChickinDetail = ({
); );
}, },
}, },
{
header: 'Periode',
accessorKey: 'period',
},
{ {
header: 'FCR Layer', header: 'FCR Layer',
accessorKey: 'fcr.name', accessorKey: 'fcr.name',
@@ -278,6 +275,10 @@ const ProjectFlockChickinDetail = ({
accessorKey: 'kandang.capacity', accessorKey: 'kandang.capacity',
header: 'Kapasitas', header: 'Kapasitas',
}, },
{
accessorFn: () => projectFlock?.period,
header: 'Periode',
},
{ {
accessorKey: 'approval.step_name', accessorKey: 'approval.step_name',
header: 'Status', header: 'Status',
@@ -42,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!')
@@ -29,7 +29,6 @@ import {
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/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
@@ -42,6 +41,8 @@ import ApprovalSteps, {
useApprovalSteps, useApprovalSteps,
} from '@/components/pages/ApprovalSteps'; } from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line'; import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import NumberInput from '@/components/input/NumberInput';
interface ProjectFlockFormProps { interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -72,8 +73,11 @@ const ProjectFlockForm = ({
const [optionsKandang, setOptionsKandang] = useState<Kandang[]>( const [optionsKandang, setOptionsKandang] = useState<Kandang[]>(
initialValues?.kandangs ?? [] initialValues?.kandangs ?? []
); );
const [selectedFlock, setSelectedFlock] = useState<number | undefined>( const [selectedFlock, setSelectedFlock] = useState<string | undefined>(
initialValues?.flock?.id ?? 0 initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
) ?? ''
); );
const deleteModal = useModal(); const deleteModal = useModal();
@@ -102,9 +106,13 @@ 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]);
@@ -143,15 +151,14 @@ const ProjectFlockForm = ({
mutate: refreshKandang, mutate: refreshKandang,
} = useSWR(kandangUrl, KandangApi.getAllFetcher); } = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, isLoading: isLoadingPeriodFlocks } = useSWR( const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
`${selectedFlock?.toString()}/periods`, `${selectedFlock?.toString()}/periods`,
(id: string) => ProjectFlockApi.getNextPeriod(id) () => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
); );
const { const {
approvals, approvals,
isLoading: approvalsLoading, isLoading: approvalsLoading,
rawDataApprovals: rawDataApprovals,
refresh: refreshApprovals, refresh: refreshApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.approval, latestApproval: initialValues?.approval,
@@ -182,6 +189,7 @@ const ProjectFlockForm = ({
formik.setFieldValue('kandang_ids', selectedRowIds); formik.setFieldValue('kandang_ids', selectedRowIds);
} }
} }
refreshPeriodFlocks();
} }
}, [kandang, selectedLocation]); }, [kandang, selectedLocation]);
useEffect(() => { useEffect(() => {
@@ -278,13 +286,24 @@ 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?.flock_name, flock: initialValues?.flock_name
flock: initialValues?.flock
? { ? {
value: initialValues?.flock?.id ?? 0, value:
optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.value ?? 0,
label: label:
initialValues?.flock?.name ?? initialValues?.flock_name ?? '', formType != 'detail'
? (optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.label ?? '')
: initialValues?.flock_name,
} }
: null, : null,
area: initialValues?.area area: initialValues?.area
@@ -311,31 +330,56 @@ const ProjectFlockForm = ({
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
flock_id: initialValues?.flock?.id ?? 0, flock_name:
flock_name: initialValues?.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 ?? 1,
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: { initialValues: {
name: initialValues?.flock_name, flock: initialValues?.flock_name
flock: initialValues?.flock
? { ? {
value: initialValues?.flock?.id ?? 0, value:
optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.value ?? 0,
label: label:
initialValues?.flock?.name ?? initialValues?.flock_name ?? '', formType != 'detail'
? (optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label ?? '')
: initialValues?.flock_name,
} }
: null, : null,
area: initialValues?.area area: initialValues?.area
@@ -362,15 +406,24 @@ const ProjectFlockForm = ({
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
flock_id: initialValues?.flock?.id ?? 0, flock_name:
flock_name: initialValues?.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, 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 ?? 1,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as ( kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number | number
| undefined | undefined
@@ -385,12 +438,11 @@ const ProjectFlockForm = ({
onSubmit: async (values) => { onSubmit: async (values) => {
setProjectFlockFormErrorMessage(''); setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = { const payload: CreateProjectFlockPayload = {
flock_name: values.flock?.label as string, 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[],
}; };
@@ -419,8 +471,6 @@ const ProjectFlockForm = ({
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]);
@@ -449,15 +499,6 @@ const ProjectFlockForm = ({
formik.validateForm(); formik.validateForm();
}, [formik.values]); }, [formik.values]);
useEffect(() => {
if (isResponseSuccess(periodFlocks)) {
formik.setFieldValue('period', periodFlocks.data.next_period);
}
if (isResponseError(periodFlocks)) {
console.log(periodFlocks?.message as string);
}
}, [periodFlocks]);
useEffect(() => { useEffect(() => {
const selectedRowIds = Object.keys(rowSelection) const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id]) .filter((id) => rowSelection[id])
@@ -485,42 +526,41 @@ 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);
} }
toast.success(approveProjectFlockRes.message as string); if (isResponseError(approvalRes)) {
} toast.error(approvalRes?.message as string);
if (isResponseError(approveProjectFlockRes)) {
toast.error(approveProjectFlockRes?.message as string);
} }
refreshApprovals(); refreshApprovals();
confirmModal.closeModal(); confirmModal.closeModal();
setIsApproveLoading(false); setIsApproveLoading(false);
}; };
const selectedPeriod = isResponseSuccess(periodFlocks)
? periodFlocks.data.find((kandang) =>
formik.values.kandang_ids?.includes(kandang.id)
)?.period
: undefined;
const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
return ( return (
<> <>
<section className='w-full'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4 mb-6'>
<Button <Button
href='/production/project-flock' href='/production/project-flock'
variant='link' variant='link'
@@ -532,6 +572,7 @@ const ProjectFlockForm = ({
<h1 className='text-2xl font-bold text-center'> <h1 className='text-2xl font-bold text-center'>
{formType === 'add' && 'Tambah Project Flock'} {formType === 'add' && 'Tambah Project Flock'}
{formType === 'edit' && 'Edit Project Flock'}
{formType === 'detail' && 'Detail Project Flock'} {formType === 'detail' && 'Detail Project Flock'}
</h1> </h1>
</header> </header>
@@ -555,7 +596,7 @@ const ProjectFlockForm = ({
</div> </div>
</div> </div>
)} )}
{approvals && !approvalsLoading && ( {approvals && !approvalsLoading && formType == 'detail' && (
<ApprovalSteps approvals={approvals} /> <ApprovalSteps approvals={approvals} />
)} )}
{formType == 'detail' && ( {formType == 'detail' && (
@@ -615,7 +656,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
@@ -634,10 +674,19 @@ 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( formik.setFieldValue(
'flock_name', 'flock_name',
(val as OptionType)?.label (val as OptionType)?.label
@@ -701,21 +750,13 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType === 'detail'} isDisabled={formType === 'detail'}
/> />
<TextInput <NumberInput
required
type='number'
name='period' name='period'
label='Periode' label='Periode'
placeholder='Masukkan periode yang project' disabled
value={formik.values.period ?? (1 as number)} readOnly
onChange={formik.handleChange} placeholder='Period'
isError={ value={selectedLocation ? inputPeriod : ''}
formik.touched.period && Boolean(formik.errors.period)
}
errorMessage={formik.errors.period as string}
readOnly={formType === 'detail'}
disabled={true}
isLoading={isLoadingPeriodFlocks}
/> />
</div> </div>
</div> </div>
@@ -750,12 +791,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 ?? []} initialValues={initialValues}
/> />
</div> </div>
</Collapse> </Collapse>
@@ -832,7 +876,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',
}} }}
@@ -844,12 +888,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={{
@@ -859,10 +903,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,
});
}, },
}} }}
/> />

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