Compare commits

...

163 Commits

Author SHA1 Message Date
Rivaldi A N S 96babba4bb Merge branch 'feat/FE/US-33/TASK-40-slicing-ui-for-master-data-customers-and-suppliers-forms' into 'feat/FE/US-33/TASK-40-slicing-ui-for-master-data-forms'
[FEAT/FE/US#33/TASK#40] Slicing UI for Costumer and Suppliers Forms in Master Data

See merge request mbugroup/lti-web-client!7
2025-10-09 08:19:28 +00:00
Rivaldi A N S acd66b0323 Merge branch 'bugfix/FE/ISSUE/build-error' into 'feat/FE/US-33/TASK-40-slicing-ui-for-master-data-forms'
[FIX/FE][ISSUE] Fix Issue for Next.js Build

See merge request mbugroup/lti-web-client!4
2025-10-09 07:50:58 +00:00
randy-ar 5cf98ed95e fix(FE-33): fix conflict git 2025-10-09 13:35:30 +07:00
randy-ar a83452a0e4 feat(FE-33): create suppliers table and forms 2025-10-09 12:27:59 +07:00
rstubryan 3f712a638f chore: remove redundant start scripts from package.json 2025-10-09 11:45:27 +07:00
rstubryan 0ea78fee92 chore: resolve conflict pull request 2025-10-09 11:44:38 +07:00
rstubryan b561ed6193 fix: update dependencies in multiple tables to include updateSortingFilter in effect dependencies 2025-10-09 11:35:22 +07:00
rstubryan d7ae7e00d3 fix: update dependency in AreasTable to include updateFilter in sorting effect 2025-10-09 11:22:01 +07:00
rstubryan f0eabedcb2 refactor: refactor layout components to use SuspenseHelper for loading states 2025-10-09 11:06:59 +07:00
ValdiANS 94a5ce5604 feat(FE-40): create layout for /detail route and wrap the children in SuspenseHelper component 2025-10-09 11:05:55 +07:00
ValdiANS 9b56308cf0 feat(FE-40): create SuspenseHelper component 2025-10-09 11:05:30 +07:00
ValdiANS 6353b3aee4 feat(FE-40,41): create Master Data Edit FCR page 2025-10-09 10:05:24 +07:00
ValdiANS 527a155997 feat(FE-40,41): create Master Data Detail FCR page 2025-10-09 10:05:17 +07:00
ValdiANS f6163b1f69 feat(FE-40,41): create Master Data Add FCR page 2025-10-09 10:05:07 +07:00
ValdiANS d771b20956 feat(FE-43): create Master Data FCR page 2025-10-09 10:04:55 +07:00
ValdiANS da91201dde chore(FE-40): use optional chaining 2025-10-09 10:04:42 +07:00
ValdiANS 9b13ce2be6 chore(FE-40): render error message if isError and errorMessage exist 2025-10-09 10:04:20 +07:00
ValdiANS 764dacc627 feat(FE-43): create FcrsTable component 2025-10-09 10:04:01 +07:00
rstubryan d1f43c4e42 refactor: rename layout files for consistency and clarity 2025-10-09 10:00:38 +07:00
ValdiANS 5c0da471ae feat(FE-42): create Fcr form validation schema 2025-10-09 10:00:29 +07:00
ValdiANS 95556bfdd7 feat(FE-40,41): create FcrForm component 2025-10-09 09:59:53 +07:00
ValdiANS 24269d8c76 feat(FE-41): create Fcr api service 2025-10-09 09:59:34 +07:00
ValdiANS a6be56e6f2 feat(FE-41): create Fcr api type 2025-10-09 09:57:41 +07:00
ValdiANS f8f5e8403a chore(FE-40): create main drawer menu type 2025-10-09 09:55:14 +07:00
rstubryan 9f2add3a57 feat: add multiple layout components with suspense fallback for loading states 2025-10-09 09:38:16 +07:00
rstubryan 3051e931ca fix: enhance MainDrawer with typed MenuItem structure and submenu handling 2025-10-09 09:12:07 +07:00
randy-ar 21cc01fe68 feat(FE-33): create customers table and details 2025-10-09 04:36:57 +07:00
sweetpotet 21b9396323 feat(FE-33): create customers forms 2025-10-08 16:40:30 +07:00
ValdiANS 1d7f100507 feat(FE-40,41): create Master Data Edit Bank page 2025-10-08 15:38:10 +07:00
ValdiANS 372f1698ca feat(FE-40,41): create Master Data Detail Bank page 2025-10-08 15:38:01 +07:00
ValdiANS 0d5e8383fd feat(FE-40,41): create Master Data Add Bank page 2025-10-08 15:37:26 +07:00
ValdiANS b9015ed673 feat(FE-43): create Master Data Bank page 2025-10-08 15:37:01 +07:00
ValdiANS ca42570a40 feat(FE-43): create BanksTable component 2025-10-08 15:36:13 +07:00
ValdiANS 16a15fce66 feat(FE-42): create Bank form validation schema 2025-10-08 15:35:56 +07:00
ValdiANS 8c507aa410 feat(FE-40,41): create BankForm component 2025-10-08 15:35:34 +07:00
ValdiANS 10749f06da feat(FE-41): create Bank API service 2025-10-08 15:35:15 +07:00
ValdiANS 293f457ecb feat(FE-41): create Bank type 2025-10-08 15:35:00 +07:00
ValdiANS 780c0bb9d0 chore(FE-41): use Nonstock API service 2025-10-08 15:01:45 +07:00
ValdiANS b8548b72c9 feat(FE-40,41): create Nonstock form validation schema 2025-10-08 15:00:41 +07:00
ValdiANS c53f91ec3f feat(FE-43): create NonstocksTable component 2025-10-08 15:00:30 +07:00
ValdiANS 96fea80f62 feat(FE-40,41): create NonstockForm component 2025-10-08 15:00:06 +07:00
ValdiANS d24d50474d feat(FE-41): create Nonstock API service 2025-10-08 14:59:39 +07:00
ValdiANS f3d0e12bcd feat(FE-42): create flags type 2025-10-08 14:59:14 +07:00
ValdiANS 143d640a1e chore(FE-41): refactor nonstock type 2025-10-08 14:58:54 +07:00
ValdiANS 0e49e29002 feat(FE-42): create SUPPLIER_FLAG_OPTIONS constant 2025-10-08 14:58:21 +07:00
ValdiANS 8461667ca2 chore(FE-41): delete nonstock api helper function file 2025-10-08 14:56:52 +07:00
ValdiANS 3e7da624aa feat: add .prettierrc.json config 2025-10-08 13:47:56 +07:00
Rivaldi A N S 1968761b5d Merge branch 'feat/FE/US-33/TASK-40-slicing-ui-for-master-data-product-and-product-category-forms' into 'feat/FE/US-33/TASK-40-slicing-ui-for-master-data-forms'
[FEAT/FE][US#33/TASK#40-43] Implement Product & Product Category Feature (Type, Validation, Form, Table, and Pages)

See merge request mbugroup/lti-web-client!3
2025-10-08 03:08:01 +00:00
rstubryan 396ebe5001 feat(FE-43): add 'Code' column to Product Category table 2025-10-08 09:55:19 +07:00
rstubryan f5952f5a36 feat(FE-43): add Supplier API and data types for supplier management 2025-10-08 09:12:49 +07:00
rstubryan 250c42a04b feat(FE-40,41,42): add Product management pages with form handling and table display 2025-10-07 21:23:12 +07:00
rstubryan e1569c607c feat(FE-41,43): add Products table with CRUD operations and search functionality 2025-10-07 21:22:05 +07:00
rstubryan 3241cc9868 feat(FE-42): add Product API and form validation schema with product flags 2025-10-07 21:21:24 +07:00
rstubryan 26ec456937 feat(FE-40,41,42): add Product Category detail and edit pages with form handling 2025-10-07 18:59:31 +07:00
rstubryan 2b9b8e9920 feat(FE-41,43): implement Product Category table with CRUD operations 2025-10-07 18:58:38 +07:00
rstubryan 3cac49725f feat(FE-42): add Product Category API and form validation schema 2025-10-07 18:56:58 +07:00
rstubryan 4f0e02a93b chore: add .idea to .gitignore 2025-10-07 17:23:58 +07:00
ValdiANS af60e682ee chore(FE-41): redirect to /404 if response error 2025-10-05 16:19:37 +07:00
ValdiANS dcebd53c45 feat(FE-40): add edit button 2025-10-05 16:13:04 +07:00
ValdiANS 1ae2d13335 feat(FE-40,41): create Master Data Edit Warehouse page 2025-10-05 16:07:27 +07:00
ValdiANS 452139eeed feat(FE-40,41): create Master Data Detail Warehouse page 2025-10-05 16:07:17 +07:00
ValdiANS eb10dfe29f feat(FE-40,41): create Master Data Add Warehouse page 2025-10-05 16:07:06 +07:00
ValdiANS 70bdfc3b43 feat(FE-43): create Master Data Warehouse page 2025-10-05 16:06:51 +07:00
ValdiANS c1bc7beb4a feat(FE-43): create WarehousesTable component 2025-10-05 16:06:36 +07:00
ValdiANS 76cd64de5b feat(FE-42): create Warehouse form validation schema 2025-10-05 16:06:21 +07:00
ValdiANS 07691bfd9e feat(FE-40,41): create WarehouseForm component 2025-10-05 16:05:58 +07:00
ValdiANS 1f0c58d264 feat(FE-40): create WAREHOUSE_TYPE_OPTIONS constant 2025-10-05 16:05:33 +07:00
ValdiANS 19ce3989ba feat(FE-41): create Warehouse API service 2025-10-05 16:05:18 +07:00
ValdiANS a136ee1190 chore(FE-41): create Override type 2025-10-05 16:04:59 +07:00
ValdiANS acd28e5deb feat(FE-41): create warehouse api type 2025-10-05 16:04:46 +07:00
ValdiANS bfc81da349 feat(FE-40,41): create Master Data Edit Kandang page 2025-10-05 13:42:22 +07:00
ValdiANS 64bb87f92f feat(FE-40,41): create Master Data Detail Kandang page 2025-10-05 13:42:14 +07:00
ValdiANS f0c2910469 feat(FE-40,41): create Master Data Add Kandang page 2025-10-05 13:42:05 +07:00
ValdiANS 952110d7af feat(FE-43): create Master Data Kandang page 2025-10-05 13:41:45 +07:00
ValdiANS 6441a38a9d feat(FE-42): create Kandang form validation schema 2025-10-05 13:41:27 +07:00
ValdiANS 531a257e78 feat(FE-40,41): create KandangForm component 2025-10-05 13:41:08 +07:00
ValdiANS be844312d3 feat(FE-43): create KandangsTable component 2025-10-05 13:40:46 +07:00
ValdiANS 6ff19f05fd feat(FE-41): craete KandangApi service 2025-10-05 13:40:09 +07:00
ValdiANS 26093034fa feat(FE-41): create user API service 2025-10-05 13:39:38 +07:00
ValdiANS 0e5b718fd7 feat(FE-41): create kandang type 2025-10-05 13:39:25 +07:00
ValdiANS 0675d95a2a feat(FE-41): create user type 2025-10-05 13:39:16 +07:00
ValdiANS d5294e9b0b chore(FE-43): remove unnecessary code 2025-10-05 13:01:30 +07:00
ValdiANS 8c84e08f3b chore(FE-41): use .d.ts extension for types 2025-10-05 13:00:58 +07:00
ValdiANS f32e1ceec4 chore(FE-41): use BaseApiService class 2025-10-05 12:59:35 +07:00
ValdiANS f7b0933c0f feat(FE-41): create BaseApiService class 2025-10-05 12:59:15 +07:00
ValdiANS 508a530c3a chore(FE-43): add address and area sorting 2025-10-05 12:32:51 +07:00
ValdiANS 05a67bdc75 feat(FE-40,41): create Master Data Edit Location page 2025-10-04 14:59:44 +07:00
ValdiANS 54d2c85677 feat(FE-40,41): create Master Data Detail Location page 2025-10-04 14:59:30 +07:00
ValdiANS 288e4b92ff feat(FE-40,41): create Master Data Add Location page 2025-10-04 14:59:16 +07:00
ValdiANS 7e0dd1bdb1 feat(FE-43): create Master Data Location page 2025-10-04 14:59:03 +07:00
ValdiANS 57e5fafabd feat(FE-42): create Location form validation schema 2025-10-04 14:58:45 +07:00
ValdiANS e53d4e22b2 feat(FE-40,41): create LocationForm component 2025-10-04 14:58:25 +07:00
ValdiANS 3c0babb62b feat(FE-43): create LocationsTable component 2025-10-04 14:57:59 +07:00
ValdiANS e7e5456d15 feat(FE-41): create LocationApi service 2025-10-04 14:57:43 +07:00
ValdiANS d3977a0951 feat(FE-41): create Location type 2025-10-04 14:57:17 +07:00
ValdiANS 5b1dab2860 feat(FE-40): add onInputChange prop 2025-10-04 14:53:05 +07:00
ValdiANS 8ed12578b4 chore(FE-40): fix name input placeholder 2025-10-04 14:32:42 +07:00
ValdiANS 7ea599168c chore(FE-43): add conditional to set sorting and setSorting and add manualSorting props 2025-10-04 14:25:29 +07:00
ValdiANS a2345165c1 chore(FE-41): create BaseArea type 2025-10-04 14:09:03 +07:00
ValdiANS e1c34cf0fb chore(FE-41): create BaseUom type 2025-10-04 14:08:53 +07:00
ValdiANS 4332881ba6 chore(FE-40): add delete button and delete confirmation modal 2025-10-04 14:03:53 +07:00
ValdiANS 73cefbb7a3 chore(FE-40): change delete confirmation modal text 2025-10-04 14:03:27 +07:00
ValdiANS 9ba7b5dba4 feat(FE-40,41): create Master Data Edit Area page 2025-10-04 13:58:53 +07:00
ValdiANS 172d8efd8e feat(FE-40,41): create Master Data Detail Area page 2025-10-04 13:58:11 +07:00
ValdiANS 69ecacc1be feat(FE-40,41): create Master Data Add Area page 2025-10-04 13:57:48 +07:00
ValdiANS 4bf4981fd4 feat(FE-43): create Master Data Area page 2025-10-04 13:57:21 +07:00
ValdiANS 6dd6147c29 feat(FE-41): create AreaApi service 2025-10-04 13:57:05 +07:00
ValdiANS c494f8dbd5 feat(FE-41): create Area type 2025-10-04 13:56:51 +07:00
ValdiANS 211951132a feat(FE-42): create Area form validation schema 2025-10-04 13:56:17 +07:00
ValdiANS b82637fb3b feat(FE-40,41): create AreaForm component 2025-10-04 13:55:52 +07:00
ValdiANS eebc9940cc feat(FE-43): create AreasTable component 2025-10-04 13:55:28 +07:00
ValdiANS 777f0f5e81 feat(FE-40): add none type to Color 2025-10-04 12:23:31 +07:00
ValdiANS d29d1f27f8 chore(FE-41): create CreatedUser and BaseMetadata type 2025-10-04 12:23:14 +07:00
ValdiANS bbe55ee4c3 chore(FE-43): add ROWS_OPTIONS constant 2025-10-04 12:22:43 +07:00
ValdiANS 6dec9268c9 feat(FE-41): create Master Data API Service class 2025-10-04 12:22:25 +07:00
ValdiANS b5d9c55fbc chore(FE-41): create UOM type 2025-10-04 12:22:00 +07:00
ValdiANS d941674f9a feat(FE-40,41): create Master Data Edit UOM page 2025-10-04 12:21:38 +07:00
ValdiANS e6c14f57d9 feat(FE-40,41): create Master Data Detail UOM page 2025-10-04 12:21:14 +07:00
ValdiANS f27b261869 feat(FE-40,41): create Master Data Add UOM page 2025-10-04 12:20:19 +07:00
ValdiANS 6a396ccce6 feat(FE-43): create Master Data UOM page 2025-10-04 12:19:52 +07:00
ValdiANS 65e3833cd5 feat(FE-43): create RowDropdownOptions component 2025-10-04 12:19:15 +07:00
ValdiANS 34e9e60173 feat(FE-43): create RowCollapseOptions component 2025-10-04 12:19:01 +07:00
ValdiANS f1a8fda667 feat(FE-42): create UOM form validation schema 2025-10-04 12:18:39 +07:00
ValdiANS 36113f6c2a feat(FE-40,41): create UomForm component 2025-10-04 12:18:15 +07:00
ValdiANS e259d1720c feat(FE-43): create UomsTable component 2025-10-04 12:17:42 +07:00
ValdiANS 78750060de feat(FE-40): create ConfirmationModal component 2025-10-04 12:17:17 +07:00
ValdiANS ae159b9617 chore(FE-40): remove unnecessary dependencies 2025-10-04 12:15:06 +07:00
ValdiANS 56476c7dd9 chore(FE-40): adjust loading dots size 2025-10-04 12:14:47 +07:00
ValdiANS fa5d09e4fb chore(FE-40): add Toaster component in root layout 2025-10-04 12:14:18 +07:00
ValdiANS df1b4c29e5 feat(FE-43): create useTableFilter hooks 2025-10-04 12:13:49 +07:00
ValdiANS 20f6686afc chore(FE-43): add sorting and setSorting props 2025-10-04 12:08:08 +07:00
ValdiANS 18027f0bb9 chore(FE-43): get setPageSize from table object 2025-10-04 11:31:23 +07:00
ValdiANS 2976ffffbf chore(FE-40): install use-debounce 2025-10-04 11:18:23 +07:00
ValdiANS 5983a44311 chore(FE-40): add DebouncedTextInput component 2025-10-04 11:18:08 +07:00
ValdiANS 42dd91117e chore(FE-40): install react-hot-toast 2025-10-04 11:01:21 +07:00
ValdiANS 60d0d77dff feat(FE-40): add Alert component 2025-10-03 22:13:35 +07:00
ValdiANS 83701a9689 chore(FE-40): update table pageSize if it change 2025-10-03 14:12:02 +07:00
ValdiANS e765a7a5fb chore(FE-40): hide empty content if is loading 2025-10-02 16:50:37 +07:00
ValdiANS 75a5caa63b feat(FE-40): create Nonstock Edit page 2025-10-02 12:03:23 +07:00
ValdiANS c40c707c17 feat(FE-40): create Nonstock Detail page 2025-10-02 12:03:06 +07:00
ValdiANS c3da39ef1b feat(FE-40): create Add Nonstock page 2025-10-02 12:02:21 +07:00
ValdiANS 230e966197 feat(FE-40): create Nonstock page 2025-10-02 12:02:11 +07:00
ValdiANS 62b3894983 feat(FE-40): create api type for nonstock 2025-10-02 12:02:02 +07:00
ValdiANS 8dd1ebdfe4 feat(FE-40): create api service for nonstock 2025-10-02 12:01:43 +07:00
ValdiANS 35c809193b feat(FE-40): create NonstocksTable component 2025-10-02 12:01:14 +07:00
ValdiANS e6acfc1214 feat(FE-40): create NonstockForm component 2025-10-02 12:01:00 +07:00
ValdiANS 36b66d9b2f feat(FE-40): create Dashboard page 2025-10-02 12:00:18 +07:00
ValdiANS 5c73f8f4af chore: update toggle dependencies 2025-10-02 12:00:02 +07:00
ValdiANS 2a6f2a1646 chore(FE-40): update MainDrawer component styling 2025-10-02 11:46:27 +07:00
ValdiANS d40a5dd898 chore: update Collapse styling 2025-10-02 11:46:09 +07:00
ValdiANS ca9205618a feat: add Modal component 2025-10-02 11:45:11 +07:00
ValdiANS 14046a1add chore(FE-40): create isResponseError api helper function 2025-10-01 16:02:12 +07:00
ValdiANS 8ad49a4480 chore(FE-40): export ErrorApiResponse and SucessApiResponse type 2025-10-01 16:01:47 +07:00
ValdiANS 4ff196cb9d chore(FE-40): remove unnecessary gap 2025-10-01 15:32:35 +07:00
ValdiANS 0afde48135 chore(FE-40): set correct page title 2025-10-01 15:24:33 +07:00
ValdiANS 9b2930375d chore(FE-40): fix bank link 2025-10-01 15:00:44 +07:00
ValdiANS 8a6a1e6b5c chore(FE-40): use RequireAuth in root layout 2025-10-01 15:00:06 +07:00
ValdiANS 6924aef8c4 feat(FE-40): create RequireAuth helper component 2025-10-01 14:59:46 +07:00
ValdiANS fa96d7a98a chore(FE-40): set opts.auth default to 'cookie' and export SWRHttpKey type 2025-10-01 14:08:02 +07:00
ValdiANS 3d3df42576 chore(FE-40): update import path and return isLoadingUser and setIsLoadingUser in useAuth 2025-10-01 14:07:30 +07:00
ValdiANS 05886896f1 chore(FE-40): update import path 2025-10-01 14:07:04 +07:00
ValdiANS a347024188 chore(FE-40): update CollapseMenu styling 2025-10-01 14:04:50 +07:00
ValdiANS 6969a2bcb8 chore(FE-40): move api.d.ts to /types/api/api-general.d.ts 2025-10-01 13:56:59 +07:00
140 changed files with 11959 additions and 93 deletions
+6
View File
@@ -39,3 +39,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# prettier
.prettierrc
# idea
.idea
+15
View File
@@ -0,0 +1,15 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"endOfLine": "lf",
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"semi": true,
"tabWidth": 2,
"trailingComma": "es5"
}
+40
View File
@@ -17,9 +17,11 @@
"next": "15.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"react-select": "^5.10.2",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
"yup": "^1.7.0",
"zustand": "^5.0.8"
},
@@ -4039,6 +4041,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -5760,6 +5771,23 @@
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
"license": "MIT"
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -6792,6 +6820,18 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-debounce": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz",
"integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
+2
View File
@@ -18,9 +18,11 @@
"next": "15.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"react-select": "^5.10.2",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.6",
"yup": "^1.7.0",
"zustand": "^5.0.8"
},
+9
View File
@@ -0,0 +1,9 @@
const Dashboard = () => {
return (
<section className='w-full p-4'>
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
</section>
);
};
export default Dashboard;
+8 -1
View File
@@ -1,7 +1,10 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import '@/app/globals.css';
import { Toaster } from 'react-hot-toast';
import MainDrawer from '@/components/MainDrawer';
import RequireAuth from '@/components/helper/RequireAuth';
const inter = Inter({
variable: '--font-inter',
@@ -27,7 +30,11 @@ export default function RootLayout({
return (
<html lang='en'>
<body className={`${inter.variable} antialiased font-inter`}>
<MainDrawer>{children}</MainDrawer>
<RequireAuth>
<MainDrawer>{children}</MainDrawer>
</RequireAuth>
<Toaster />
</body>
</html>
);
+11
View File
@@ -0,0 +1,11 @@
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
const AddNonstock = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<AreaForm />
</div>
);
};
export default AddNonstock;
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
import { AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const AreaEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const areaId = searchParams.get('areaId');
const { data: area, isLoading: isLoadingArea } = useSWR(
areaId,
(id: number) => AreaApi.getSingle(id)
);
if (!areaId) {
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 (!isLoadingArea && (!area || isResponseError(area))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingArea && <span className='loading loading-spinner loading-xl' />}
{!isLoadingArea && isResponseSuccess(area) && (
<AreaForm type='edit' initialValues={area.data} />
)}
</div>
);
};
export default AreaEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import AreaForm from '@/components/pages/master-data/area/form/AreaForm';
import { AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const AreaDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const areaId = searchParams.get('areaId');
const { data: area, isLoading: isLoadingArea } = useSWR(
areaId,
(id: number) => AreaApi.getSingle(id)
);
if (!areaId) {
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 (!isLoadingArea && (!area || isResponseError(area))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingArea && <span className='loading loading-spinner loading-xl' />}
{!isLoadingArea && isResponseSuccess(area) && (
<AreaForm type='detail' initialValues={area.data} />
)}
</div>
);
};
export default AreaDetail;
+11
View File
@@ -0,0 +1,11 @@
import AreasTable from '@/components/pages/master-data/area/AreasTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<AreasTable />
</section>
);
};
export default Nonstock;
+11
View File
@@ -0,0 +1,11 @@
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
const AddBank = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<BankForm />
</div>
);
};
export default AddBank;
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
import { BankApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const BankEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const bankId = searchParams.get('bankId');
const { data: bank, isLoading: isLoadingBank } = useSWR(
bankId,
(id: number) => BankApi.getSingle(id)
);
if (!bankId) {
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 (!isLoadingBank && (!bank || isResponseError(bank))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingBank && <span className='loading loading-spinner loading-xl' />}
{!isLoadingBank && isResponseSuccess(bank) && (
<BankForm type='edit' initialValues={bank.data} />
)}
</div>
);
};
export default BankEdit;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import BankForm from '@/components/pages/master-data/bank/form/BankForm';
import { BankApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const BankDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const bankId = searchParams.get('bankId');
const { data: bank, isLoading: isLoadingBank } = useSWR(
bankId,
(id: number) => BankApi.getSingle(id)
);
if (!bankId) {
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 (!isLoadingBank && (!bank || isResponseError(bank))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingBank && <span className='loading loading-spinner loading-xl' />}
{!isLoadingBank && isResponseSuccess(bank) && (
<BankForm type='detail' initialValues={bank.data} />
)}
</div>
);
};
export default BankDetail;
+11
View File
@@ -0,0 +1,11 @@
import BanksTable from '@/components/pages/master-data/bank/BanksTable';
const Bank = () => {
return (
<section className='w-full p-4'>
<BanksTable />
</section>
);
};
export default Bank;
+11
View File
@@ -0,0 +1,11 @@
import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm";
const AddCustomer = () => {
return (
<section className="w-full p-4 flex flex-row justify-center">
<CustomerForm/>
</section>
);
}
export default AddCustomer;
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { CustomerApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm';
const CustomerEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const costumerId = searchParams.get('customerId');
const { data: costumer, isLoading: isLoadingCostumer } = useSWR(
costumerId,
(id: number) => CustomerApi.getSingle(id)
);
if (!costumerId) {
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 (!isLoadingCostumer && (!costumer || isResponseError(costumer))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingCostumer && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingCostumer && isResponseSuccess(costumer) && (
<CustomerForm formType='edit' initialValues={costumer.data} />
)}
</div>
);
};
export default CustomerEdit;
@@ -0,0 +1,45 @@
'use client'
import { useRouter, useSearchParams } from "next/navigation";
import useSWR from "swr";
import { CustomerApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from "@/lib/api-helper";
import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm";
const CustomerDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const costumerId = searchParams.get("customerId");
const { data: costumer, isLoading: isLoadingCostumer } = useSWR(
costumerId,
(id: number) => CustomerApi.getSingle(id)
);
if(!costumerId){
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(!isLoadingCostumer && (!costumer || isResponseError(costumer))){
router.replace("/404");
return;
}
return (
<div className="w-full p-4 flex flex-row justify-center">
{isLoadingCostumer && <span className="loading loading-spinner loading-xl" />}
{!isLoadingCostumer && isResponseSuccess(costumer) && (
<CustomerForm formType="detail" initialValues={costumer.data} />
)}
</div>
)
};
export default CustomerDetail;
+11
View File
@@ -0,0 +1,11 @@
import CustomersTable from "@/components/pages/master-data/customer/CustomersTable";
const Customer = () => {
return (
<section className="w-full p-4">
<CustomersTable />
</section>
)
};
export default Customer;
+11
View File
@@ -0,0 +1,11 @@
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
const AddFcr = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<FcrForm />
</div>
);
};
export default AddFcr;
@@ -0,0 +1,52 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
import { FcrApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { FcrWithStandards } from '@/types/api/master-data/fcr';
const FcrEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const fcrId = searchParams.get('fcrId');
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
fcrId,
(id: number) =>
FcrApi.getSingle(id) as Promise<
BaseApiResponse<FcrWithStandards> | undefined
>
);
if (!fcrId) {
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 (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
{!isLoadingFcr && isResponseSuccess(fcr) && (
<FcrForm type='edit' initialValues={fcr.data} />
)}
</div>
);
};
export default FcrEdit;
+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;
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import FcrForm from '@/components/pages/master-data/fcr/form/FcrForm';
import { FcrApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { FcrWithStandards } from '@/types/api/master-data/fcr';
import { BaseApiResponse } from '@/types/api/api-general';
const FcrDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const fcrId = searchParams.get('fcrId');
const { data: fcr, isLoading: isLoadingFcr } = useSWR(
fcrId,
(id: number) =>
FcrApi.getSingle(id) as Promise<
BaseApiResponse<FcrWithStandards> | undefined
>
);
if (!fcrId) {
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 (!isLoadingFcr && (!fcr || isResponseError(fcr))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingFcr && <span className='loading loading-spinner loading-xl' />}
{!isLoadingFcr && isResponseSuccess(fcr) && (
<FcrForm type='detail' initialValues={fcr.data} />
)}
</div>
);
};
export default FcrDetail;
+11
View File
@@ -0,0 +1,11 @@
import FcrsTable from '@/components/pages/master-data/fcr/FcrsTable';
const Fcr = () => {
return (
<section className='w-full p-4'>
<FcrsTable />
</section>
);
};
export default Fcr;
+11
View File
@@ -0,0 +1,11 @@
import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
const AddNonstock = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<KandangForm />
</div>
);
};
export default AddNonstock;
@@ -0,0 +1,49 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
import { KandangApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const KandangEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: kandang, isLoading: isLoadingKandang } = useSWR(
kandangId,
(id: number) => KandangApi.getSingle(id)
);
if (!kandangId) {
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 (!isLoadingKandang && (!kandang || isResponseError(kandang))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingKandang && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingKandang && isResponseSuccess(kandang) && (
<KandangForm type='edit' initialValues={kandang.data} />
)}
</div>
);
};
export default KandangEdit;
@@ -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 KandangForm from '@/components/pages/master-data/kandang/form/KandangForm';
import { KandangApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const KandangDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: kandang, isLoading: isLoadingKandang } = useSWR(
kandangId,
(id: number) => KandangApi.getSingle(id)
);
if (!kandangId) {
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 (!isLoadingKandang && (!kandang || isResponseError(kandang))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingKandang && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingKandang && isResponseSuccess(kandang) && (
<KandangForm type='detail' initialValues={kandang.data} />
)}
</div>
);
};
export default KandangDetail;
+11
View File
@@ -0,0 +1,11 @@
import KandangsTable from '@/components/pages/master-data/kandang/KandangsTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<KandangsTable />
</section>
);
};
export default Nonstock;
+11
View File
@@ -0,0 +1,11 @@
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
const AddNonstock = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<LocationForm />
</div>
);
};
export default AddNonstock;
@@ -0,0 +1,49 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import LocationForm from '@/components/pages/master-data/location/form/LocationForm';
import { LocationApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const LocationEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const locationId = searchParams.get('locationId');
const { data: location, isLoading: isLoadingLocation } = useSWR(
locationId,
(id: number) => LocationApi.getSingle(id)
);
if (!locationId) {
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 (!isLoadingLocation && (!location || isResponseError(location))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingLocation && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingLocation && isResponseSuccess(location) && (
<LocationForm type='edit' initialValues={location.data} />
)}
</div>
);
};
export default LocationEdit;
@@ -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 LocationForm from '@/components/pages/master-data/location/form/LocationForm';
import { LocationApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const LocationDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const locationId = searchParams.get('locationId');
const { data: location, isLoading: isLoadingLocation } = useSWR(
locationId,
(id: number) => LocationApi.getSingle(id)
);
if (!locationId) {
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 (!isLoadingLocation && (!location || isResponseError(location))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingLocation && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingLocation && isResponseSuccess(location) && (
<LocationForm type='detail' initialValues={location.data} />
)}
</div>
);
};
export default LocationDetail;
+11
View File
@@ -0,0 +1,11 @@
import LocationsTable from '@/components/pages/master-data/location/LocationsTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<LocationsTable />
</section>
);
};
export default Nonstock;
+11
View File
@@ -0,0 +1,11 @@
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
const AddNonstock = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<NonstockForm />
</div>
);
};
export default AddNonstock;
@@ -0,0 +1,49 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
import { NonstockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const NonstockEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const nonstockId = searchParams.get('nonstockId');
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
nonstockId,
(id: number) => NonstockApi.getSingle(id)
);
if (!nonstockId) {
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 (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingNonstock && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingNonstock && isResponseSuccess(nonstock) && (
<NonstockForm type='edit' initialValues={nonstock.data} />
)}
</div>
);
};
export default NonstockEdit;
@@ -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 NonstockForm from '@/components/pages/master-data/nonstock/form/NonstockForm';
import { NonstockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const NonstockDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const nonstockId = searchParams.get('nonstockId');
const { data: nonstock, isLoading: isLoadingNonstock } = useSWR(
nonstockId,
(id: number) => NonstockApi.getSingle(id)
);
if (!nonstockId) {
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 (!isLoadingNonstock && (!nonstock || isResponseError(nonstock))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingNonstock && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingNonstock && isResponseSuccess(nonstock) && (
<NonstockForm type='detail' initialValues={nonstock.data} />
)}
</div>
);
};
export default NonstockDetail;
+11
View File
@@ -0,0 +1,11 @@
import NonstocksTable from '@/components/pages/master-data/nonstock/NonstocksTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<NonstocksTable />
</section>
);
};
export default Nonstock;
@@ -0,0 +1,11 @@
import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm";
const AddProductCategory = () => {
return (
<div className="w-full p-4 flex flex-row justify-center">
<ProductCategoryForm />
</div>
);
};
export default AddProductCategory;
@@ -0,0 +1,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
import { ProductCategoryApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ProductCategoryEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const productCategoryId = searchParams.get('productCategoryId');
const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
productCategoryId,
(id: number) => ProductCategoryApi.getSingle(id)
);
if (!productCategoryId) {
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 (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />}
{!isLoadingProductCategory && isResponseSuccess(productCategory) && (
<ProductCategoryForm type='edit' initialValues={productCategory.data} />
)}
</div>
);
}
export default ProductCategoryEdit;
@@ -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,47 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
import { ProductCategoryApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ProductCategoryDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const productCategoryId = searchParams.get('productCategoryId');
const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
productCategoryId,
(id: number) => ProductCategoryApi.getSingle(id)
);
if (!productCategoryId) {
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 (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingProductCategory && <span className='loading loading-spinner loading-xl' />}
{!isLoadingProductCategory && isResponseSuccess(productCategory) && (
<ProductCategoryForm type='detail' initialValues={productCategory.data} />
)}
</div>
);
};
export default ProductCategoryDetail;
@@ -0,0 +1,11 @@
import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable";
const ProductCategory = () => {
return (
<section className="w-full p-4">
<ProductCategoryTable />
</section>
);
};
export default ProductCategory;
+11
View File
@@ -0,0 +1,11 @@
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
const AddProduct = () => {
return (
<div className="w-full p-4 flex flex-row justify-center">
<ProductForm />
</div>
);
};
export default AddProduct;
@@ -0,0 +1,45 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
import { ProductApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ProductEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const productId = searchParams.get('productId');
const { data: product, isLoading } = useSWR(
productId,
(id: number) => ProductApi.getSingle(id)
);
if (!productId) {
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 && (!product || isResponseError(product))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(product) && (
<ProductForm type='edit' initialValues={product.data} />
)}
</div>
);
};
export default ProductEdit;
@@ -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,45 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
import { ProductApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const ProductDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const productId = searchParams.get('productId');
const { data: product, isLoading } = useSWR(
productId,
(id: number) => ProductApi.getSingle(id)
);
if (!productId) {
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 && (!product || isResponseError(product))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(product) && (
<ProductForm type='detail' initialValues={product.data} />
)}
</div>
);
};
export default ProductDetail;
+11
View File
@@ -0,0 +1,11 @@
import ProductsTable from "@/components/pages/master-data/product/ProductTable";
const Product = () => {
return (
<section className="w-full p-4">
<ProductsTable />
</section>
);
};
export default Product;
+11
View File
@@ -0,0 +1,11 @@
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
const AddSupplier = () => {
return (
<section className='w-full p-4 flex flex-row justify-center'>
<SupplierForm />
</section>
);
};
export default AddSupplier;
@@ -0,0 +1,49 @@
'use client';
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { SupplierApi } from '@/services/api/master-data';
import { useSearchParams, useRouter } from 'next/navigation';
import useSWR from 'swr';
const SupplierEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
// Get Query Params
const supplierId = searchParams.get('supplierId');
// Fetch Data
const { data: supplier, isLoading: isLoadingSupplier } = useSWR(
supplierId,
(id: number) => SupplierApi.getSingle(id)
);
if (!supplierId) {
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 (!isLoadingSupplier && (!supplier || isResponseError(supplier))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingSupplier && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingSupplier && isResponseSuccess(supplier) && (
<SupplierForm formType='edit' initialValues={supplier.data} />
)}
</div>
);
};
export default SupplierEdit;
@@ -0,0 +1,49 @@
'use client';
import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { SupplierApi } from '@/services/api/master-data';
import { useSearchParams, useRouter } from 'next/navigation';
import useSWR from 'swr';
const SupplierDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
// Get Query Params
const supplierId = searchParams.get('supplierId');
// Fetch Data
const { data: supplier, isLoading: isLoadingSupplier } = useSWR(
supplierId,
(id: number) => SupplierApi.getSingle(id)
);
if (!supplierId) {
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 (!isLoadingSupplier && (!supplier || isResponseError(supplier))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingSupplier && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingSupplier && isResponseSuccess(supplier) && (
<SupplierForm formType='detail' initialValues={supplier.data} />
)}
</div>
);
};
export default SupplierDetail;
+11
View File
@@ -0,0 +1,11 @@
import SuppliersTable from "@/components/pages/master-data/supplier/SupplierTable";
const Supplier = () => {
return (
<section className='w-full p-4'>
<SuppliersTable />
</section>
);
};
export default Supplier;
+11
View File
@@ -0,0 +1,11 @@
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
const AddNonstock = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<UomForm />
</div>
);
};
export default AddNonstock;
@@ -0,0 +1,46 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
import { UomApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const UomEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const uomId = searchParams.get('uomId');
const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) =>
UomApi.getSingle(id)
);
if (!uomId) {
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 (!isLoadingUom && (!uom || isResponseError(uom))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingUom && <span className='loading loading-spinner loading-xl' />}
{!isLoadingUom && isResponseSuccess(uom) && (
<UomForm type='edit' initialValues={uom.data} />
)}
</div>
);
};
export default UomEdit;
+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;
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import UomForm from '@/components/pages/master-data/uom/form/UomForm';
import { UomApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const UomDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const uomId = searchParams.get('uomId');
const { data: uom, isLoading: isLoadingUom } = useSWR(uomId, (id: number) =>
UomApi.getSingle(id)
);
if (!uomId) {
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 (!isLoadingUom && (!uom || isResponseError(uom))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingUom && <span className='loading loading-spinner loading-xl' />}
{!isLoadingUom && isResponseSuccess(uom) && (
<UomForm type='detail' initialValues={uom.data} />
)}
</div>
);
};
export default UomDetail;
+11
View File
@@ -0,0 +1,11 @@
import UomsTable from '@/components/pages/master-data/uom/UomsTable';
const Nonstock = () => {
return (
<section className='w-full p-4'>
<UomsTable />
</section>
);
};
export default Nonstock;
@@ -0,0 +1,11 @@
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
const AddNonstock = () => {
return (
<div className='w-full p-4 flex flex-row justify-center'>
<WarehouseForm />
</div>
);
};
export default AddNonstock;
@@ -0,0 +1,49 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
import { WarehouseApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const WarehouseEdit = () => {
const router = useRouter();
const searchParams = useSearchParams();
const warehouseId = searchParams.get('warehouseId');
const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR(
warehouseId,
(id: number) => WarehouseApi.getSingle(id)
);
if (!warehouseId) {
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 (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingWarehouse && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingWarehouse && isResponseSuccess(warehouse) && (
<WarehouseForm type='edit' initialValues={warehouse.data} />
)}
</div>
);
};
export default WarehouseEdit;
@@ -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 WarehouseForm from '@/components/pages/master-data/warehouse/form/WarehouseForm';
import { WarehouseApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
const WarehouseDetail = () => {
const router = useRouter();
const searchParams = useSearchParams();
const warehouseId = searchParams.get('warehouseId');
const { data: warehouse, isLoading: isLoadingWarehouse } = useSWR(
warehouseId,
(id: number) => WarehouseApi.getSingle(id)
);
if (!warehouseId) {
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 (!isLoadingWarehouse && (!warehouse || isResponseError(warehouse))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingWarehouse && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingWarehouse && isResponseSuccess(warehouse) && (
<WarehouseForm type='detail' initialValues={warehouse.data} />
)}
</div>
);
};
export default WarehouseDetail;
+11
View File
@@ -0,0 +1,11 @@
import WarehousesTable from '@/components/pages/master-data/warehouse/WarehousesTable';
const Warehouse = () => {
return (
<section className='w-full p-4'>
<WarehousesTable />
</section>
);
};
export default Warehouse;
+27
View File
@@ -0,0 +1,27 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
interface AlertProps {
variant?: 'outline' | 'dash' | 'soft';
color?: 'info' | 'success' | 'warning' | 'error';
children?: ReactNode;
className?: string;
}
const Alert = ({ children, variant, color, className }: AlertProps) => {
const alertBaseClassName = cn('alert', {
'alert-soft': variant === 'soft',
'alert-outline': variant === 'outline',
'alert-dash': variant === 'dash',
'alert-info': color === 'info',
'alert-success': color === 'success',
'alert-warning': color === 'warning',
'alert-error': color === 'error',
});
return <div className={cn(alertBaseClassName, className)}>{children}</div>;
};
export default Alert;
+2 -2
View File
@@ -61,7 +61,7 @@ const Button = ({
)}
>
{!isLoading && children}
{isLoading && <span className='loading loading-dots loading-xl' />}
{isLoading && <span className='loading loading-dots loading-md' />}
</button>
)}
@@ -76,7 +76,7 @@ const Button = ({
)}
>
{!isLoading && children}
{isLoading && <span className='loading loading-dots loading-xl' />}
{isLoading && <span className='loading loading-dots loading-md' />}
</Link>
)}
</>
+3 -2
View File
@@ -70,6 +70,7 @@ export const Collapse = ({
variant === 'plus' && 'collapse-plus',
bordered && 'border base-content/20 border-opacity-20 rounded-box',
disabled && 'opacity-60 pointer-events-none',
!open && 'w-fit',
className
);
@@ -82,7 +83,7 @@ export const Collapse = ({
</div>
);
}
return <div>{title}</div>;
return title;
}, [title, subtitle]);
return (
@@ -102,7 +103,7 @@ export const Collapse = ({
role='button'
tabIndex={0}
className={cn(
'collapse-title p-0',
'collapse-title w-fit p-0',
'focus:outline-none focus-visible:ring focus-visible:ring-primary/40',
titleClassName
)}
+71 -38
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { usePathname } from 'next/navigation';
import Image from 'next/image';
@@ -29,15 +29,11 @@ const isPathActive = (pathname: string, link?: string) => {
const splittedPathname = pathname.split('/');
const splittedLink = link.split('/');
return splittedPathname.every((pathnameChunk, idx) => {
return pathnameChunk === splittedLink[idx];
const isActiveLinkValid = splittedLink.every((linkChunk, idx) => {
return linkChunk === splittedPathname[idx];
});
};
const isCollapseActive = (pathname: string, link?: string) => {
if (!link) return false;
return pathname === link || pathname.startsWith(link);
return pathname.startsWith(link) && isActiveLinkValid;
};
const CollapseMenu = ({
@@ -48,14 +44,15 @@ const CollapseMenu = ({
depth = 0,
}: CollapseMenuProps) => {
const pathname = usePathname();
const [open, setOpen] = useState(isCollapseActive(pathname, link));
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',
'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': open,
'bg-primary/10 opacity-100': open || isActive,
}
)}
>
@@ -76,42 +73,48 @@ const CollapseMenu = ({
</div>
);
const paddingLeftDepth = `pl-${4 * (depth + 1)}`;
return (
<Collapse
open={open}
title={menuCollapseTitle}
onOpenChange={setOpen}
titleClassName='p-0!'
className='w-full'
titleClassName='w-full p-0!'
>
<Menu className={cn('py-0.5', paddingLeftDepth)}>
{submenu?.map((item, idx) => {
const hasSubmenu = item.submenu && item.submenu.length > 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)}
/>
);
}
if (!hasSubmenu) {
return (
<MenuItem
<CollapseMenu
key={idx}
title={item.title}
href={item.link}
link={item.link}
icon={item.icon}
active={isPathActive(pathname, item.link)}
submenu={item.submenu}
depth={depth + 1}
/>
);
}
return (
<CollapseMenu
key={idx}
title={item.title}
link={item.link}
icon={item.icon}
submenu={item.submenu}
depth={depth + 1}
/>
);
})}
})}
</div>
</Menu>
</Collapse>
);
@@ -178,9 +181,39 @@ const MainDrawer = ({
const { mainDrawerOpen, setMainDrawerOpen } = useUiStore();
const pathname = usePathname();
const pageTitle = MAIN_DRAWER_LINKS.find((item) =>
pathname.startsWith(item.link)
)?.title;
const getPageTitle = useCallback(() => {
let title = '';
const activeMenu = MAIN_DRAWER_LINKS.find((item) =>
isPathActive(pathname, item.link)
);
const traverseMenuTitle = (menu: typeof activeMenu) => {
if (!menu) return;
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
if (!title) {
title += menu?.title;
} else {
title += ' - ' + menu?.title;
}
if (!hasSubmenu || !menu.submenu) return;
const activeSubmenu = menu.submenu?.find((item) =>
isPathActive(pathname, item.link)
);
traverseMenuTitle(activeSubmenu);
};
traverseMenuTitle(activeMenu);
return title;
}, [pathname]);
const pageTitle = getPageTitle();
const toggleSidebar = () => {
setMainDrawerOpen(!mainDrawerOpen);
@@ -193,7 +226,7 @@ const MainDrawer = ({
openOnLarge
sidebarContent={<MainDrawerContent />}
>
<main className='w-full h-full flex flex-col gap-4'>
<main className='w-full h-full flex flex-col'>
<Navbar title={pageTitle as string} toggleSidebar={toggleSidebar} />
{children}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
import { cn } from '@/lib/helper';
export const useModal = () => {
const ref = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
const openModal = useCallback(() => {
setOpen(true);
ref.current?.showModal();
}, []);
const closeModal = useCallback(() => {
setOpen(false);
ref.current?.close();
}, []);
const toggle = useCallback(() => {
if (open) {
closeModal();
} else {
openModal();
}
}, [open, closeModal, openModal]);
if (ref.current) {
ref.current.addEventListener('close', () => {
closeModal();
});
}
return { ref, open, setOpen, openModal, closeModal, toggle } as const;
};
interface ModalProps {
ref: RefObject<HTMLDialogElement | null>;
children?: ReactNode;
closeOnBackdrop?: boolean;
className?: {
modal?: string;
modalBox?: string;
};
}
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
return (
<dialog ref={ref} className={cn('modal', className?.modal)}>
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
{closeOnBackdrop && (
<form method='dialog' className='modal-backdrop'>
<button>close</button>
</form>
)}
</dialog>
);
};
export default Modal;
+24 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { ReactNode, useCallback, useState } from 'react';
import { ReactNode, useCallback, useEffect, useState } from 'react';
import {
flexRender,
getCoreRowModel,
@@ -11,6 +11,8 @@ import {
useReactTable,
ColumnDef,
FilterFn,
SortingState,
OnChangeFn,
} from '@tanstack/react-table';
import { rankItem } from '@tanstack/match-sorter-utils';
import { Icon } from '@iconify/react';
@@ -43,6 +45,9 @@ export interface TableProps<TData extends object> {
onFuzzySearchValueChange?: (value: string) => void;
className?: TableClassNames;
emptyContent?: ReactNode;
sorting?: SortingState;
setSorting?: OnChangeFn<SortingState>;
manualSorting?: boolean;
}
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -78,6 +83,9 @@ const Table = <TData extends object>({
paginationClassName: '',
},
emptyContent = emptyContentDefaultValue,
sorting,
setSorting,
manualSorting = false,
}: TableProps<TData>) => {
const isServerSideTable =
totalItems !== undefined &&
@@ -105,6 +113,7 @@ const Table = <TData extends object>({
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
manualSorting,
state: {
pagination,
globalFilter: fuzzySearchValue,
@@ -120,7 +129,16 @@ const Table = <TData extends object>({
tableOptions.getFilteredRowModel = getFilteredRowModel();
}
if (sorting && setSorting) {
tableOptions.onSortingChange = setSorting;
tableOptions.state = {
...tableOptions.state,
sorting,
};
}
const table = useReactTable(tableOptions);
const { setPageSize } = table;
const prevPageClickHandler = () => {
table.previousPage();
@@ -148,6 +166,10 @@ const Table = <TData extends object>({
}
};
useEffect(() => {
setPageSize(pageSize);
}, [pageSize, setPageSize]);
return (
<div className={className.containerClassName}>
<div className={className.tableWrapperClassName}>
@@ -224,6 +246,7 @@ const Table = <TData extends object>({
</div>
{(data.length === 0 || table.getRowModel().rows.length === 0) &&
!isLoading &&
emptyContent}
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
+197
View File
@@ -0,0 +1,197 @@
'use client';
import { ReactNode, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import useSWRImmutable from 'swr/immutable';
import { useAuth } from '@/services/hooks/useAuth';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { GetMeResponse } from '@/types/api/api-general';
// 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 {
children?: ReactNode;
}
const RequireAuth = ({ children }: RequireAuthProps) => {
const router = useRouter();
const { setUser, setIsLoadingUser } = useAuth();
const { data: userResponse, isLoading: isLoadingUserResponse } =
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>(
'/auth/get-me',
httpClientFetcher,
{
shouldRetryOnError: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 0,
}
);
useEffect(() => {
setIsLoadingUser(isLoadingUserResponse);
}, [isLoadingUserResponse, setIsLoadingUser]);
useEffect(() => {
if (isResponseSuccess(userResponse)) {
setUser(userResponse.data);
} else {
// router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
// TODO: remove this later, DONT HARDCODE USER DATA
setUser(DUMMY_USER);
}
}, [userResponse, setIsLoadingUser, setUser]);
// TODO: uncomment this later
// if (isLoadingUserResponse && !userResponse) {
// return (
// <div className='w-full flex flex-row justify-center items-center p-4'>
// <span className='loading loading-spinner loading-xl' />
// </div>
// );
// }
return <>{children}</>;
};
export default RequireAuth;
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { Suspense } from 'react';
const SuspenseHelper = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<Suspense
fallback={
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
}
>
{children}
</Suspense>
);
};
export default SuspenseHelper;
@@ -0,0 +1,42 @@
'use client';
import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface DebouncedTextInputProps extends TextInputProps {
delay?: number;
}
const DebouncedTextInput = (props: DebouncedTextInputProps) => {
const { delay, onChange } = props;
const [internalChangeEvent, setInternalChangeEvent] =
useState<ChangeEvent<HTMLInputElement>>();
const [internalValue, setInternalValue] = useState(props.value);
const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300);
const [debouncedValue] = useDebounce(internalValue, delay ?? 300);
const internalChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
setInternalValue(e.target.value);
setInternalChangeEvent(e);
};
useEffect(() => {
if (debouncedChangeEvent) {
onChange?.(debouncedChangeEvent);
}
}, [debouncedValue]);
return (
<TextInput
{...props}
value={internalValue}
onChange={internalChangeHandler}
/>
);
};
export default DebouncedTextInput;
+21 -2
View File
@@ -1,8 +1,9 @@
'use client';
import { ComponentType, ReactNode, useMemo } from 'react';
import Select, { OptionProps, GroupBase } from 'react-select';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import Select, { OptionProps, GroupBase, InputActionMeta } from 'react-select';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn } from '@/lib/helper';
@@ -41,6 +42,8 @@ interface SelectInputProps<T = OptionType> {
errorMessage?: string;
isAnimated?: boolean;
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
}
const animatedComponents = makeAnimated();
@@ -65,7 +68,13 @@ const SelectInput = <T extends OptionType>({
errorMessage,
isAnimated = true,
openMenu,
delay = 300,
onInputChange,
}: SelectInputProps) => {
const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay ?? 300);
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
@@ -75,6 +84,14 @@ const SelectInput = <T extends OptionType>({
};
}, [isAnimated]);
const internalInputChangeHandler = (value: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(value);
if (meta.action === 'menu-close') setInternalInputValue('');
};
useEffect(() => {
onInputChange?.(debouncedInputValue);
}, [debouncedInputValue]);
return (
<div
className={cn(
@@ -110,6 +127,8 @@ const SelectInput = <T extends OptionType>({
onChange={(val) => onChange?.(val as T)}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
isMulti={isMulti}
isDisabled={isDisabled}
isLoading={isLoading}
+169
View File
@@ -0,0 +1,169 @@
'use client';
import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react';
import { cn } from '@/lib/helper';
export interface TagInputProps {
label?: string;
bottomLabel?: string;
name: string;
value?: string;
placeholder?: string;
className?: {
wrapper?: string;
label?: string;
inputWrapper?: string;
input?: string;
};
isError?: boolean;
isValid?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
isLoading?: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
}
const TagInput: React.FC<TagInputProps> = ({
label,
bottomLabel,
name,
value = '',
placeholder,
className,
isError,
isValid,
errorMessage,
disabled = false,
readOnly = false,
required = false,
onChange,
}) => {
const [tags, setTags] = useState<string[]>(value ? value.split(',') : []);
const [inputValue, setInputValue] = useState('');
useEffect(() => {
if (value !== undefined && value !== tags.join(',')) {
setTags(value ? value.split(',') : []);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const newTag = inputValue.trim();
if (newTag && !tags.includes(newTag)) {
const updatedTags = [...tags, newTag];
setTags(updatedTags);
onChange?.(updatedTags.join(','));
}
setInputValue('');
}
};
const handleRemoveTag = (tagToRemove: string) => {
const updatedTags = tags.filter((t) => t !== tagToRemove);
setTags(updatedTags);
onChange?.(updatedTags.join(','));
};
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{/* Label */}
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{ 'text-error': isError },
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label>
)}
{/* Input wrapper */}
<div
className={cn(
'flex flex-wrap items-start gap-2 border border-gray-400 rounded-md p-2 focus-within:ring-2 focus-within:ring-blue-500 min-h-[42px] transition-all',
{
'border-error': isError,
'border-success!': isValid,
'opacity-70 cursor-not-allowed': disabled,
},
className?.inputWrapper
)}
onClick={() => {
// Fokuskan input saat area diklik
const inputEl = document.getElementById(name);
inputEl?.focus();
}}
>
{tags.map((tag) => (
<div
key={tag}
className={cn(
'badge badge-primary gap-1 px-3 py-3 text-white flex items-center'
)}
>
<span>{tag}</span>
{!readOnly && (
<button
type='button'
onClick={() => handleRemoveTag(tag)}
className='ml-1 text-white hover:text-red-200 focus:outline-none'
>
</button>
)}
</div>
))}
{!readOnly && (
<input
type='text'
id={name}
name={name}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className={cn(
'flex-1 min-w-[120px] border-none outline-none p-1 size-min',
className?.input
)}
/>
)}
</div>
{/* Bottom label or error message */}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
export default TagInput;
+124
View File
@@ -0,0 +1,124 @@
'use client';
import {
ChangeEventHandler,
FocusEventHandler,
ReactNode,
} from 'react';
import { cn } from '@/lib/helper';
export interface TextAreaProps {
label?: string;
bottomLabel?: string;
name: string;
value?: string | number;
placeholder?: string;
className?: {
wrapper?: string;
label?: string;
inputWrapper?: string;
input?: string;
};
isError?: boolean;
isValid?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
isLoading?: boolean;
errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLTextAreaElement>;
onBlur?: FocusEventHandler<HTMLTextAreaElement>;
cols?: number;
}
const TextArea = ({
label,
bottomLabel,
name,
value,
placeholder,
className,
isError,
isValid,
errorMessage,
startAdornment,
endAdornment,
disabled = false,
required = false,
onChange,
onBlur,
readOnly = false,
isLoading = false,
cols = 3
}: TextAreaProps) => {
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label>
)}
{startAdornment && startAdornment}
<textarea
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
id={name}
name={name}
placeholder={placeholder}
value={value}
cols={cols}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
export default TextArea;
+3 -1
View File
@@ -122,7 +122,9 @@ const TextInput = ({
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
)}
</div>
);
};
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { RefObject } from 'react';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import { cn } from '@/lib/helper';
import { Color } from '@/types/theme';
interface ConfirmationModalProps {
ref: RefObject<HTMLDialogElement | null>;
type?: 'info' | 'success' | 'error';
text?: string;
closeOnBackdrop?: boolean;
primaryButton?: {
text?: string;
color?: Color;
isLoading?: boolean;
onClick?: () => void;
};
secondaryButton?: {
text?: string;
color?: Color;
isLoading?: boolean;
onClick?: () => void;
};
className?: {
modal?: string;
modalBox?: string;
};
}
const ConfirmationModal = ({
ref,
type = 'info',
text,
closeOnBackdrop,
primaryButton,
secondaryButton,
className,
}: ConfirmationModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
return (
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
<div className='w-full flex flex-col gap-4'>
<div
className={cn(
'w-fit p-4 mx-auto flex flex-row justify-center items-center rounded-full',
{
'bg-error': type === 'error',
'bg-info': type === 'info',
'bg-success': type === 'success',
}
)}
>
{type === 'info' && (
<Icon
icon='material-symbols:info-outline-rounded'
width={64}
height={64}
className='text-info-content'
/>
)}
{type === 'success' && (
<Icon
icon='qlementine-icons:success-12'
width={64}
height={64}
className='text-success-content'
/>
)}
{type === 'error' && (
<Icon
icon='solar:danger-triangle-linear'
width={64}
height={64}
className='text-error-content'
/>
)}
</div>
<p className='text-center font-medium'>
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
</p>
<div className='w-full flex flex-row gap-2'>
{secondaryButton && secondaryButton.text && (
<Button
variant='ghost'
color={secondaryButton?.color ?? 'none'}
isLoading={secondaryButton?.isLoading}
disabled={secondaryButton?.isLoading}
onClick={closeModalHandler}
className='grow'
>
{secondaryButton?.text ?? 'Tidak'}
</Button>
)}
{primaryButton && primaryButton.text && (
<Button
color={primaryButton?.color ?? 'info'}
onClick={primaryButton?.onClick}
isLoading={primaryButton?.isLoading}
disabled={primaryButton?.isLoading}
className='grow'
>
{primaryButton?.text ?? 'Ya'}
</Button>
)}
</div>
</div>
</Modal>
);
};
export default ConfirmationModal;
@@ -0,0 +1,276 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Area, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const AreasTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: areas,
isLoading,
mutate: refreshAreas,
} = useSWR(
`${AreaApi.basePath}${getTableFilterQueryString()}`,
AreaApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const areasColumns: ColumnDef<Area>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedArea(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await AreaApi.delete(selectedArea?.id as number);
refreshAreas();
deleteModal.closeModal();
toast.success('Successfully delete Area!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/area/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Area
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Area'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Area>
data={isResponseSuccess(areas) ? areas?.data : []}
columns={areasColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(areas) ? areas?.meta?.page : 0}
totalItems={isResponseSuccess(areas) ? areas?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(areas) && areas?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Area ini (${selectedArea?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default AreasTable;
@@ -0,0 +1,9 @@
import * as Yup from 'yup';
export const AreaFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
});
export const UpdateAreaFormSchema = AreaFormSchema;
export type AreaFormValues = Yup.InferType<typeof AreaFormSchema>;
@@ -0,0 +1,253 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
AreaFormSchema,
AreaFormValues,
UpdateAreaFormSchema,
} from '@/components/pages/master-data/area/form/AreaForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
Area,
CreateAreaPayload,
UpdateAreaPayload,
} from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
interface AreaFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Area;
}
const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [areaFormErrorMessage, setAreaFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createAreaHandler = useCallback(
async (payload: CreateAreaPayload) => {
const createAreaRes = await AreaApi.create(payload);
if (isResponseError(createAreaRes)) {
setAreaFormErrorMessage(createAreaRes.message);
return;
}
toast.success(createAreaRes?.message as string);
router.push('/master-data/area');
},
[router]
);
const updateAreaHandler = useCallback(
async (areaId: number, payload: UpdateAreaPayload) => {
const updateAreaRes = await AreaApi.update(areaId, payload);
if (updateAreaRes?.status === 'error') {
setAreaFormErrorMessage(updateAreaRes.message);
return;
}
toast.success(updateAreaRes?.message as string);
router.refresh();
router.push('/master-data/area');
},
[router]
);
const formikInitialValues = useMemo<AreaFormValues>(() => {
return {
name: initialValues?.name ?? '',
};
}, [initialValues]);
const formik = useFormik<AreaFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateAreaFormSchema : AreaFormSchema,
onSubmit: async (values) => {
setAreaFormErrorMessage('');
const areaPayload: CreateAreaPayload = {
name: values.name,
};
switch (type) {
case 'add':
await createAreaHandler(areaPayload);
break;
case 'edit':
await updateAreaHandler(initialValues?.id as number, areaPayload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const deleteAreaClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await AreaApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Area!');
setIsDeleteLoading(false);
router.push('/master-data/area');
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/area'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Area'}
{type === 'edit' && 'Edit Area'}
{type === 'detail' && 'Detail Area'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama area'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteAreaClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/area/detail/edit/?areaId=${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>
{areaFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{areaFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Area ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default AreaForm;
@@ -0,0 +1,289 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Bank, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const BanksTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: banks,
isLoading,
mutate: refreshBanks,
} = useSWR(
`${BankApi.basePath}${getTableFilterQueryString()}`,
BankApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const banksColumns: ColumnDef<Bank>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'alias',
header: 'Alias',
},
{
accessorKey: 'account_number',
header: 'No. Rekening',
},
{
accessorKey: 'owner',
header: 'Pemilik',
cell: (props) => (props.getValue() ? props.getValue() : '-'),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedBank(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await BankApi.delete(selectedBank?.id as number);
refreshBanks();
deleteModal.closeModal();
toast.success('Successfully delete Bank!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting]);
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='flex flex-row'>
<Button href='/master-data/bank/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Bank
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Bank'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Bank>
data={isResponseSuccess(banks) ? banks?.data : []}
columns={banksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(banks) ? banks?.meta?.page : 0}
totalItems={isResponseSuccess(banks) ? banks?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(banks) && banks?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Bank ini (${selectedBank?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default BanksTable;
@@ -0,0 +1,14 @@
import * as Yup from 'yup';
export const BankFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
alias: Yup.string()
.max(5, 'Maksimal 5 karakter!')
.required('Alias wajib diisi!'),
account_number: Yup.string().required('Rekening wajib diisi!'),
owner: Yup.string(),
});
export const UpdateBankFormSchema = BankFormSchema;
export type BankFormValues = Yup.InferType<typeof BankFormSchema>;
@@ -0,0 +1,301 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
BankFormSchema,
BankFormValues,
UpdateBankFormSchema,
} from '@/components/pages/master-data/bank/form/BankForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
CreateBankPayload,
Bank,
UpdateBankPayload,
} from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
interface BankFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Bank;
}
const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [bankFormErrorMessage, setBankFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createBankHandler = useCallback(
async (payload: CreateBankPayload) => {
const createBankRes = await BankApi.create(payload);
if (isResponseError(createBankRes)) {
setBankFormErrorMessage(createBankRes.message);
return;
}
toast.success(createBankRes?.message as string);
router.push('/master-data/bank');
},
[router]
);
const updateBankHandler = useCallback(
async (bankId: number, payload: UpdateBankPayload) => {
const updateBankRes = await BankApi.update(bankId, payload);
if (updateBankRes?.status === 'error') {
setBankFormErrorMessage(updateBankRes.message);
return;
}
toast.success(updateBankRes?.message as string);
router.refresh();
router.push('/master-data/bank');
},
[router]
);
const formikInitialValues = useMemo<BankFormValues>(() => {
return {
name: initialValues?.name ?? '',
alias: initialValues?.alias ?? '',
account_number: initialValues?.account_number ?? '',
owner: initialValues?.owner,
};
}, [initialValues]);
const formik = useFormik<BankFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateBankFormSchema : BankFormSchema,
onSubmit: async (values) => {
setBankFormErrorMessage('');
const bankPayload: CreateBankPayload = {
name: values.name,
alias: values.alias,
account_number: values.account_number.toString(),
owner: values.owner ? values.owner : '',
};
switch (type) {
case 'add':
await createBankHandler(bankPayload);
break;
case 'edit':
await updateBankHandler(initialValues?.id as number, bankPayload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const deleteBankClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await BankApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Bank!');
setIsDeleteLoading(false);
router.push('/master-data/bank');
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/bank'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Bank'}
{type === 'edit' && 'Edit Bank'}
{type === 'detail' && 'Detail Bank'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama Bank'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Alias'
name='alias'
placeholder='Masukkan alias'
value={formik.values.alias}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.alias && Boolean(formik.errors.alias)}
errorMessage={formik.errors.alias}
readOnly={type === 'detail'}
/>
<TextInput
required
type='number'
label='No. Rekening'
name='account_number'
placeholder='Masukkan no. rekening'
value={formik.values.account_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.account_number &&
Boolean(formik.errors.account_number)
}
errorMessage={formik.errors.account_number}
readOnly={type === 'detail'}
/>
<TextInput
label='Pemilik'
name='owner'
placeholder='Masukkan nama pemilik'
value={formik.values.owner}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.owner && Boolean(formik.errors.owner)}
errorMessage={formik.errors.owner}
readOnly={type === 'detail'}
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteBankClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/bank/detail/edit/?bankId=${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>
{bankFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{bankFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Bank ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default BankForm;
@@ -0,0 +1,288 @@
'use client';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { CustomerApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Customer } from '@/types/api/master-data/customer';
import { Icon } from '@iconify/react';
import {
CellContext,
ColumnDef,
} from '@tanstack/react-table';
import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Customer, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type == 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/customer/detail/?customerId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
className='justify-start text-sm'
href={`/master-data/customer/detail/edit/?customerId=${props.row.original.id}`}
variant='ghost'
color='warning'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const CustomersTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '', picSort: '' },
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
picSort: 'sort_pic',
},
});
// Fetch Data
const {
data: customers,
isLoading,
mutate: refreshCustomers,
} = useSWR(
`${CustomerApi.basePath}${getTableFilterQueryString()}`,
CustomerApi.getAllFetcher
);
// State
const deleteModal = useModal();
const [selectedCustomer, setSelectedCustomer] = useState<
Customer | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// Columns Definition
const customersColumns: ColumnDef<Customer>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'pic',
header: 'PIC',
cell: (props) => props.row.original.pic.name,
},
{
accessorKey: 'type',
header: 'Type',
cell: (props) => props.row.original.type,
},
{
accessorKey: 'phone',
header: 'Phone',
},
{
accessorKey: 'email',
header: 'Email',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedCustomer(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
// Handler
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await CustomerApi.delete(selectedCustomer?.id as number);
refreshCustomers();
deleteModal.closeModal();
toast.success('Successfully delete Customer!');
setIsDeleteLoading(false);
};
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
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='flex flex-row'>
<Button href='/master-data/customer/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Customer
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Kandang'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Customer>
data={isResponseSuccess(customers) ? customers?.data : []}
columns={customersColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(customers) ? customers?.meta?.page : 0}
totalItems={
isResponseSuccess(customers) ? customers?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(customers) && customers?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Customer ini (${selectedCustomer?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default CustomersTable;
@@ -0,0 +1,37 @@
import * as Yup from 'yup';
export const CustomerFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'),
pic: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).required('PIC wajib diisi!'),
type: Yup.object({
value: Yup.string().required(),
label: Yup.string().required(),
}).required('Tipe wajib diisi!'),
address: Yup.string().required('Alamat wajib diisi!'),
phone: Yup.string()
.matches(/^[0-9]+$/, 'Nomor telepon hanya boleh berisi angka!')
.min(10, 'Nomor telepon minimal 10 digit!')
.max(12, 'Nomor telepon maksimal 12 digit!')
.required('Nomor telepon wajib diisi!'),
email: Yup.string()
.email('Format email tidak valid!')
.required('Email wajib diisi!'),
account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'),
});
export const UpdateCustomerFormSchema = CustomerFormSchema;
export type CustomerFormValues = Yup.InferType<typeof CustomerFormSchema>;
@@ -0,0 +1,410 @@
'use client';
import { useModal } from '@/components/Modal';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { CustomerApi } from '@/services/api/master-data';
import {
CreateCustomerPayload,
Customer,
UpdateCustomerPayload,
} from '@/types/api/master-data/customer';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } from './CustomerForm.schema';
import { useFormik } from 'formik';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import TextInput from '@/components/input/TextInput';
import { cn } from '@/lib/helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import TextArea from '@/components/input/TextArea';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import useSWR from 'swr';
import { UserApi } from '@/services/api/user';
import { TYPE_OPTIONS } from '@/config/constant';
interface CustomerFormProps {
formType?: 'add' | 'edit' | 'detail';
initialValues?: Customer;
}
const CustomerForm = ({
formType = 'add',
initialValues,
}: CustomerFormProps) => {
// Setup Kebutuhan Form
const router = useRouter();
const deleteModal = useModal();
// Setup State
const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [picSelectInputValue, setPicSelectInputValue] = useState('');
const [typeSelectInputValue, setTypeSelectInputValue] = useState('');
// Fetch Data
const picUrl = `${UserApi.basePath}?${new URLSearchParams({
search: picSelectInputValue ?? '',
})}`;
const { data: pic, isLoading: isLoadingPic } = useSWR(
picUrl,
UserApi.getAllFetcher
);
// -- Options data mapping
const picOptions = isResponseSuccess(pic)
? pic?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const typeOptions = TYPE_OPTIONS;
// Handler Event
const createCustomerHandler = useCallback(
async (payload: CreateCustomerPayload) => {
const createCustomerRes = await CustomerApi.create(payload);
if (isResponseError(createCustomerRes)) {
setCustomerFormErrorMessage(createCustomerRes.message);
return;
}
toast.success(createCustomerRes?.message as string);
router.push('/master-data/customer');
},
[router]
);
const updateCustomerHandler = useCallback(
async (customerId: number, payload: UpdateCustomerPayload) => {
const updateCustomerRes = await CustomerApi.update(customerId, payload);
if (isResponseError(updateCustomerRes)) {
setCustomerFormErrorMessage(updateCustomerRes.message);
return;
}
toast.success(updateCustomerRes?.message as string);
router.push('/master-data/customer');
},
[router]
);
const deleteCustomerHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteclickHandler = async () => {
setIsDeleteLoading(true);
await CustomerApi.delete(initialValues?.id as number);
deleteModal.closeModal();
setIsDeleteLoading(false);
router.push('/master-data/customer');
};
// -- Option Handler
const picChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('pic', true);
formik.setFieldValue('pic', val);
formik.setFieldTouched('picId', true);
formik.setFieldValue('picId', (val as OptionType)?.value);
};
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('type', true);
formik.setFieldValue('type', val);
};
// Utils Functions
const normalizeType = (type?: string | { value: string; label: string }) => {
if (!type) return TYPE_OPTIONS[0];
return typeof type === 'string' ? { value: type, label: type } : type;
};
// Memo untuk simpan input sebelumnya
const formikInitialValues = useMemo<CustomerFormValues>(() => {
return {
name: initialValues?.name ?? '',
email: initialValues?.email ?? '',
phone: initialValues?.phone ?? '',
picId: initialValues?.pic?.id ?? 0,
pic: initialValues?.pic
? {
value: initialValues.pic.id,
label: initialValues.pic.name,
}
: {
value: 0,
label: '',
},
type: normalizeType(initialValues?.type),
address: initialValues?.address ?? '',
account_number: initialValues?.account_number ?? '',
};
}, [initialValues]);
// Formik
const formik = useFormik<CustomerFormValues>({
initialValues: formikInitialValues,
enableReinitialize: true,
validationSchema: formType === 'edit' ? UpdateCustomerFormSchema : CustomerFormSchema,
onSubmit: async (values) => {
// reset error message
setCustomerFormErrorMessage('');
// create payload
const payload: CreateCustomerPayload = {
name: values.name,
email: values.email,
phone: values.phone,
pic_id: values.picId,
type: (values.type as OptionType).value as string,
address: values.address,
account_number: values.account_number,
};
// cek type form yang disubmit
switch (formType) {
case 'add':
await createCustomerHandler(payload);
break;
case 'edit':
await updateCustomerHandler(initialValues?.id as number, payload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
// Initialize Formik
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
// Render
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/customer'
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'>
{formType === 'add' && 'Tambah Customer'}
{formType === 'edit' && 'Ubah Customer'}
{formType === 'detail' && 'Detail Customer'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
{/* Fields Form */}
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama customer'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={formType === 'detail'}
/>
<SelectInput
required
placeholder='Pilih PIC'
label='PIC'
value={formik.values.pic ?? undefined}
onChange={picChangeHandler}
options={picOptions}
onInputChange={setPicSelectInputValue}
isLoading={isLoadingPic}
isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string}
isDisabled={formType === 'detail'}
isClearable
isSearchable={true}
/>
<SelectInput
required
placeholder='Pilih Tipe'
label='Tipe'
value={
typeOptions.find(
(item) => item.value === formik.values.type?.value
) ?? undefined
}
onChange={typeChangeHandler}
options={typeOptions}
onInputChange={setTypeSelectInputValue}
isError={formik.touched.type && Boolean(formik.errors.type)}
errorMessage={formik.errors.type as string}
isDisabled={formType === 'detail'}
isClearable
isSearchable={true}
/>
<TextInput
required
label='Email'
name='email'
placeholder='Masukkan email customer'
value={formik.values.email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.email && Boolean(formik.errors.email)}
errorMessage={formik.errors.email}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nomor Telepon'
name='phone'
placeholder='Masukkan nomor telepon customer'
value={formik.values.phone}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.phone && Boolean(formik.errors.phone)}
errorMessage={formik.errors.phone}
readOnly={formType === 'detail'}
/>
<TextInput
required
label='Nomor Rekening'
name='account_number'
placeholder='Masukkan nomor rekening customer'
value={formik.values.account_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.account_number &&
Boolean(formik.errors.account_number)
}
errorMessage={formik.errors.account_number}
readOnly={formType === 'detail'}
/>
<TextArea
required
label='Alamat'
name='address'
placeholder='Masukkan alamat customer'
value={formik.values.address}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.address && Boolean(formik.errors.address)}
errorMessage={formik.errors.address}
readOnly={formType === 'detail'}
cols={8}
/>
</div>
{/* Action Button */}
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{formType !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteCustomerHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{formType !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/customer/detail/edit/?customerId=${initialValues?.id}`}
className='px-4'
>
<Icon
icon='material-symbols:edit-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Edit
</Button>
)}
</div>
)}
{formType !== 'detail' && (
<div
className={cn('flex flex-row justify-end gap-2', {
'w-full': formType === '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>
{customerFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{customerFormErrorMessage}</span>
</div>
)}
</form>
</section>
{formType !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Customer ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: confirmationModalDeleteclickHandler,
isLoading: isDeleteLoading,
}}
/>
)}
</>
);
};
export default CustomerForm;
@@ -0,0 +1,276 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Fcr } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Fcr, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/fcr/detail/?fcrId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/fcr/detail/edit/?fcrId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const FcrsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: fcrs,
isLoading,
mutate: refreshFcrs,
} = useSWR(
`${FcrApi.basePath}${getTableFilterQueryString()}`,
FcrApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedFcr, setSelectedFcr] = useState<Fcr | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const fcrsColumns: ColumnDef<Fcr>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedFcr(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FcrApi.delete(selectedFcr?.id as number);
refreshFcrs();
deleteModal.closeModal();
toast.success('Successfully delete FCR!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting]);
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='flex flex-row'>
<Button href='/master-data/fcr/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah FCR
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari FCR'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Fcr>
data={isResponseSuccess(fcrs) ? fcrs?.data : []}
columns={fcrsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(fcrs) ? fcrs?.meta?.page : 0}
totalItems={isResponseSuccess(fcrs) ? fcrs?.meta?.total_results : 0}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20': isResponseSuccess(fcrs) && fcrs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data FCR ini (${selectedFcr?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default FcrsTable;
@@ -0,0 +1,26 @@
import * as Yup from 'yup';
const FcrStandardSchema: Yup.ObjectSchema<{
weight: number | string;
fcr_number: number | string;
mortality: number | string;
}> = Yup.object({
weight: Yup.number().nullable().required('Bobot wajib diisi!'),
fcr_number: Yup.number()
.nullable()
.typeError('FCR harus angka!')
.required('FCR harus diisi!'),
mortality: Yup.number().nullable().required('Mortalitas wajib diisi!'),
});
export const FcrFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
fcrStandards: Yup.array()
.of(FcrStandardSchema)
.min(1, 'Minimal 1 FCR Standard diisi1')
.required('FCR wajib diisi!'),
});
export const UpdateFcrFormSchema = FcrFormSchema;
export type FcrFormValues = Yup.InferType<typeof FcrFormSchema>;
@@ -0,0 +1,389 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
FcrFormSchema,
FcrFormValues,
UpdateFcrFormSchema,
} from '@/components/pages/master-data/fcr/form/FcrForm.schema';
import { isResponseError } from '@/lib/api-helper';
import {
CreateFcrPayload,
Fcr,
FcrWithStandards,
UpdateFcrPayload,
} from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
interface FcrFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: FcrWithStandards;
}
const FcrForm = ({ type = 'add', initialValues }: FcrFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [fcrFormErrorMessage, setFcrFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createFcrHandler = useCallback(
async (payload: CreateFcrPayload) => {
const createFcrRes = await FcrApi.create(payload);
if (isResponseError(createFcrRes)) {
setFcrFormErrorMessage(createFcrRes.message);
return;
}
toast.success(createFcrRes?.message as string);
router.push('/master-data/fcr');
},
[router]
);
const updateFcrHandler = useCallback(
async (fcrId: number, payload: UpdateFcrPayload) => {
const updateFcrRes = await FcrApi.update(fcrId, payload);
if (updateFcrRes?.status === 'error') {
setFcrFormErrorMessage(updateFcrRes.message);
return;
}
toast.success(updateFcrRes?.message as string);
router.refresh();
router.push('/master-data/fcr');
},
[router]
);
const formikInitialValues = useMemo<FcrFormValues>(() => {
return {
name: initialValues?.name ?? '',
fcrStandards: initialValues?.fcr_standards
? initialValues?.fcr_standards
: [
{
weight: '',
fcr_number: '',
mortality: '',
},
],
};
}, [initialValues]);
const formik = useFormik<FcrFormValues>({
initialValues: formikInitialValues,
validationSchema: type === 'edit' ? UpdateFcrFormSchema : FcrFormSchema,
onSubmit: async (values) => {
setFcrFormErrorMessage('');
const fcrPayload: CreateFcrPayload = {
name: values.name,
fcr_standards: values.fcrStandards as CreateFcrPayload['fcr_standards'],
};
switch (type) {
case 'add':
await createFcrHandler(fcrPayload);
break;
case 'edit':
await updateFcrHandler(initialValues?.id as number, fcrPayload);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const addFcrStandard = () =>
formik.setFieldValue('fcrStandards', [
...formik.values.fcrStandards,
{
weight: '',
fcr_number: '',
mortality: '',
},
]);
const removeFcrStandard = (i: number) =>
formik.setFieldValue(
'fcrStandards',
formik.values.fcrStandards.filter((_, idx) => idx !== i)
);
const deleteFcrClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FcrApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete FCR!');
setIsDeleteLoading(false);
router.push('/master-data/fcr');
};
const isRepeaterInputError = (
column: keyof CreateFcrPayload['fcr_standards'][0],
idx: number
) => {
return (
formik.touched.fcrStandards?.[idx]?.[column] &&
Boolean(
formik.errors.fcrStandards?.[idx] instanceof Object &&
formik.errors.fcrStandards?.[idx]?.[column]
)
);
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-5xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/fcr'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah FCR'}
{type === 'edit' && 'Edit FCR'}
{type === 'detail' && 'Detail FCR'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama FCR'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Bobot</th>
<th>FCR</th>
<th>Mortalitas</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
</thead>
<tbody>
{formik.values.fcrStandards.map((fcrStandard, idx) => (
<tr key={idx}>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].weight`}
placeholder='Masukkan bobot'
value={fcrStandard.weight}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('weight', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].fcr_number`}
placeholder='Masukkan FCR'
value={fcrStandard.fcr_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('fcr_number', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
required
type='number'
name={`fcrStandards[${idx}].mortality`}
placeholder='Masukkan mortalitas'
value={fcrStandard.mortality}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isRepeaterInputError('mortality', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => removeFcrStandard(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{type !== 'detail' && (
<Button
type='button'
color='success'
onClick={addFcrStandard}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah FCR
</Button>
)}
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteFcrClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/fcr/detail/edit/?fcrId=${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>
{fcrFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{fcrFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data FCR ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default FcrForm;
@@ -0,0 +1,318 @@
'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Kandang, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/kandang/detail/?kandangId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/kandang/detail/edit/?kandangId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const KandangsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '', locationSort: '', picSort: '' },
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
locationSort: 'sort_location',
picSort: ' sort_pic',
},
});
const {
data: kandangs,
isLoading,
mutate: refreshKandangs,
} = useSWR(
`${KandangApi.basePath}${getTableFilterQueryString()}`,
KandangApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedKandang, setSelectedKandang] = useState<Kandang | undefined>(
undefined
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const kandangsColumns: ColumnDef<Kandang>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location.name,
},
{
accessorKey: 'pic',
header: 'PIC',
cell: (props) => props.row.original.pic.name,
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedKandang(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await KandangApi.delete(selectedKandang?.id as number);
refreshKandangs();
deleteModal.closeModal();
toast.success('Successfully delete Kandang!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
);
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const locationSortFilter = sorting.find(
(sortItem) => sortItem.id === 'location'
);
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]);
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='flex flex-row'>
<Button href='/master-data/kandang/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Kandang
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Kandang'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Kandang ini (${selectedKandang?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default KandangsTable;
@@ -0,0 +1,23 @@
import * as Yup from 'yup';
export const KandangFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
locationId: Yup.number()
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'),
pic: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
});
export const UpdateKandangFormSchema = KandangFormSchema;
export type KandangFormValues = Yup.InferType<typeof KandangFormSchema>;
@@ -0,0 +1,360 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
KandangFormSchema,
KandangFormValues,
UpdateKandangFormSchema,
} from '@/components/pages/master-data/kandang/form/KandangForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
Kandang,
CreateKandangPayload,
UpdateKandangPayload,
} from '@/types/api/master-data/kandang';
import { LocationApi, KandangApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { UserApi } from '@/services/api/user';
interface KandangFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Kandang;
}
const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [kandangFormErrorMessage, setKandangFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createKandangHandler = useCallback(
async (payload: CreateKandangPayload) => {
const createKandangRes = await KandangApi.create(payload);
if (isResponseError(createKandangRes)) {
setKandangFormErrorMessage(createKandangRes.message);
return;
}
toast.success(createKandangRes?.message as string);
router.push('/master-data/kandang');
},
[router]
);
const updateKandangHandler = useCallback(
async (kandangId: number, payload: UpdateKandangPayload) => {
const updateKandangRes = await KandangApi.update(kandangId, payload);
if (updateKandangRes?.status === 'error') {
setKandangFormErrorMessage(updateKandangRes.message);
return;
}
toast.success(updateKandangRes?.message as string);
router.refresh();
router.push('/master-data/kandang');
},
[router]
);
const formikInitialValues = useMemo<KandangFormValues>(() => {
return {
name: initialValues?.name ?? '',
locationId: initialValues?.location?.id ?? 0,
location: initialValues?.location
? {
value: initialValues.location.id,
label: initialValues.location.name,
}
: null,
picId: initialValues?.pic?.id ?? 0,
pic: initialValues?.pic
? {
value: initialValues.pic.id,
label: initialValues.pic.name,
}
: null,
};
}, [initialValues]);
const formik = useFormik<KandangFormValues>({
initialValues: formikInitialValues,
validationSchema:
type === 'edit' ? UpdateKandangFormSchema : KandangFormSchema,
onSubmit: async (values) => {
setKandangFormErrorMessage('');
const kandangPayload: CreateKandangPayload = {
name: values.name,
location_id: values.locationId,
pic_id: values.picId,
};
switch (type) {
case 'add':
await createKandangHandler(kandangPayload);
break;
case 'edit':
await updateKandangHandler(
initialValues?.id as number,
kandangPayload
);
break;
}
},
});
const { setValues: formikSetValues } = formik;
// location
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({
search: locationSelectInputValue ?? '',
}).toString()}`;
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true);
formik.setFieldValue('location', val);
formik.setFieldTouched('locationId', true);
formik.setFieldValue('locationId', (val as OptionType)?.value);
};
// PIC
const [picSelectInputValue, setPicSelectInputValue] = useState('');
const picsUrl = `${UserApi.basePath}?${new URLSearchParams({
search: picSelectInputValue ?? '',
}).toString()}`;
const { data: pics, isLoading: isLoadingPics } = useSWR(
picsUrl,
LocationApi.getAllFetcher
);
const picOptions = isResponseSuccess(pics)
? pics?.data.map((pic) => ({
value: pic.id,
label: pic.name,
}))
: [];
const picChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('pic', true);
formik.setFieldValue('pic', val);
formik.setFieldTouched('picId', true);
formik.setFieldValue('picId', (val as OptionType)?.value);
};
const deleteKandangClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await KandangApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Kandang!');
setIsDeleteLoading(false);
router.push('/master-data/kandang');
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/kandang'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Kandang'}
{type === 'edit' && 'Edit Kandang'}
{type === 'detail' && 'Detail Kandang'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<SelectInput
required
label='Lokasi'
value={formik.values.location ?? undefined}
onChange={locationChangeHandler}
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
isError={
formik.touched.locationId && Boolean(formik.errors.locationId)
}
errorMessage={formik.errors.locationId as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
required
label='PIC'
value={formik.values.pic ?? undefined}
onChange={picChangeHandler}
options={picOptions}
onInputChange={setPicSelectInputValue}
isLoading={isLoadingPics}
isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string}
isDisabled={type === 'detail'}
isClearable
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteKandangClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/kandang/detail/edit/?kandangId=${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>
{kandangFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{kandangFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Kandang ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default KandangForm;
@@ -0,0 +1,317 @@
'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Location } from '@/types/api/master-data/location';
import { LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Location, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/location/detail/?locationId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/location/detail/edit/?locationId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const LocationsTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '', addressSort: '', areaSort: '' },
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
addressSort: 'sort_address',
areaSort: ' sort_area',
},
});
const {
data: locations,
isLoading,
mutate: refreshLocations,
} = useSWR(
`${LocationApi.basePath}${getTableFilterQueryString()}`,
LocationApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedLocation, setSelectedLocation] = useState<
Location | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const locationsColumns: ColumnDef<Location>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'address',
header: 'Alamat',
},
{
accessorKey: 'area',
header: 'Area',
cell: (props) => props.row.original.area.name,
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedLocation(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await LocationApi.delete(selectedLocation?.id as number);
refreshLocations();
deleteModal.closeModal();
toast.success('Successfully delete Location!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
);
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const addressSortFilter = sorting.find(
(sortItem) => sortItem.id === 'address'
);
const areaSortFilter = sorting.find((sortItem) => sortItem.id === 'area');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('addressSort', addressSortFilter);
updateSortingFilter('areaSort', areaSortFilter);
}, [sorting, updateSortingFilter]);
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='flex flex-row'>
<Button href='/master-data/location/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Location
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Location'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Location>
data={isResponseSuccess(locations) ? locations?.data : []}
columns={locationsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(locations) ? locations?.meta?.page : 0}
totalItems={
isResponseSuccess(locations) ? locations?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(locations) && locations?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Location ini (${selectedLocation?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default LocationsTable;
@@ -0,0 +1,18 @@
import * as Yup from 'yup';
export const LocationFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
address: Yup.string().required('Alamat wajib diisi!'),
areaId: Yup.number()
.min(1, 'Area wajib diisi!')
.required('Area wajib diisi!'),
area: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
});
export const UpdateLocationFormSchema = LocationFormSchema;
export type LocationFormValues = Yup.InferType<typeof LocationFormSchema>;
@@ -0,0 +1,322 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
LocationFormSchema,
LocationFormValues,
UpdateLocationFormSchema,
} from '@/components/pages/master-data/location/form/LocationForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
Location,
CreateLocationPayload,
UpdateLocationPayload,
} from '@/types/api/master-data/location';
import { AreaApi, LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
interface LocationFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Location;
}
const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [locationFormErrorMessage, setLocationFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createLocationHandler = useCallback(
async (payload: CreateLocationPayload) => {
const createLocationRes = await LocationApi.create(payload);
if (isResponseError(createLocationRes)) {
setLocationFormErrorMessage(createLocationRes.message);
return;
}
toast.success(createLocationRes?.message as string);
router.push('/master-data/location');
},
[router]
);
const updateLocationHandler = useCallback(
async (locationId: number, payload: UpdateLocationPayload) => {
const updateLocationRes = await LocationApi.update(locationId, payload);
if (updateLocationRes?.status === 'error') {
setLocationFormErrorMessage(updateLocationRes.message);
return;
}
toast.success(updateLocationRes?.message as string);
router.refresh();
router.push('/master-data/location');
},
[router]
);
const formikInitialValues = useMemo<LocationFormValues>(() => {
return {
name: initialValues?.name ?? '',
address: initialValues?.address ?? '',
areaId: initialValues?.area?.id ?? 0,
area: initialValues?.area
? {
value: initialValues.area.id,
label: initialValues.area.name,
}
: null,
};
}, [initialValues]);
const formik = useFormik<LocationFormValues>({
initialValues: formikInitialValues,
validationSchema:
type === 'edit' ? UpdateLocationFormSchema : LocationFormSchema,
onSubmit: async (values) => {
setLocationFormErrorMessage('');
const locationPayload: CreateLocationPayload = {
name: values.name,
address: values.address,
area_id: values.areaId,
};
switch (type) {
case 'add':
await createLocationHandler(locationPayload);
break;
case 'edit':
await updateLocationHandler(
initialValues?.id as number,
locationPayload
);
break;
}
},
});
const { setValues: formikSetValues } = formik;
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({
search: areaSelectInputValue ?? '',
}).toString()}`;
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areasUrl,
AreaApi.getAllFetcher
);
const areaOptions = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('area', true);
formik.setFieldValue('area', val);
formik.setFieldTouched('areaId', true);
formik.setFieldValue('areaId', (val as OptionType)?.value);
};
const deleteLocationClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await LocationApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Location!');
setIsDeleteLoading(false);
router.push('/master-data/location');
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/location'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Location'}
{type === 'edit' && 'Edit Location'}
{type === 'detail' && 'Detail Location'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<TextInput
required
label='Alamat'
name='address'
placeholder='Masukkan alamat lokasi '
value={formik.values.address}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.address && Boolean(formik.errors.address)}
errorMessage={formik.errors.address}
readOnly={type === 'detail'}
/>
<SelectInput
required
label='Area'
value={formik.values.area ?? undefined}
onChange={areaChangeHandler}
options={areaOptions}
onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas}
isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
errorMessage={formik.errors.areaId as string}
isDisabled={type === 'detail'}
isClearable
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteLocationClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/location/detail/edit/?locationId=${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>
{locationFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{locationFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Location ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default LocationForm;
@@ -0,0 +1,329 @@
'use client';
import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
ColumnDef,
ColumnSort,
SortingState,
} from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Nonstock, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/nonstock/detail/?nonstockId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/nonstock/detail/edit/?nonstockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const NonstocksTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '', locationSort: '', picSort: '' },
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
locationSort: 'sort_location',
picSort: ' sort_pic',
},
});
const {
data: nonstocks,
isLoading,
mutate: refreshNonstocks,
} = useSWR(
`${NonstockApi.basePath}${getTableFilterQueryString()}`,
NonstockApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedNonstock, setSelectedNonstock] = useState<
Nonstock | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const nonstocksColumns: ColumnDef<Nonstock>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'uom',
header: 'UOM',
cell: (props) => props.row.original.uom.name,
},
{
accessorKey: 'suppliers',
header: 'Supplier',
cell: (props) => {
const supplierNames = props.row.original.suppliers.map(
(supplier) => supplier.name
);
return supplierNames.join(', ') || '-';
},
},
{
accessorKey: 'flags',
header: 'Flag',
cell: (props) => props.row.original.flags?.join(', ') || '-',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedNonstock(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await NonstockApi.delete(selectedNonstock?.id as number);
refreshNonstocks();
deleteModal.closeModal();
toast.success('Successfully delete Nonstock!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const updateSortingFilter = useCallback(
(
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
sortFilter: ColumnSort | undefined
) => {
if (!sortFilter) {
updateFilter(sortName, '');
} else {
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
}
},
[updateFilter]
);
// track sorting
useEffect(() => {
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
const locationSortFilter = sorting.find(
(sortItem) => sortItem.id === 'location'
);
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('picSort', picSortFilter);
}, [sorting]);
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='flex flex-row'>
<Button href='/master-data/nonstock/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Nonstock
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Nonstock'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<Nonstock>
data={isResponseSuccess(nonstocks) ? nonstocks?.data : []}
columns={nonstocksColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(nonstocks) ? nonstocks?.meta?.page : 0}
totalItems={
isResponseSuccess(nonstocks) ? nonstocks?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(nonstocks) && nonstocks?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Nonstock ini (${selectedNonstock?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default NonstocksTable;
@@ -0,0 +1,25 @@
import * as Yup from 'yup';
export const NonstockFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
uomId: Yup.number().min(1, 'UOM wajib diisi!').required('UOM wajib diisi!'),
uom: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplierIds: Yup.array().of(Yup.number().min(0, 'Supplier wajib diisi!')),
suppliers: Yup.array().of(
Yup.object({
value: Yup.number().min(0).required(),
label: Yup.string().required(),
})
),
flags: Yup.array().of(Yup.string()).notRequired(),
});
export const UpdateNonstockFormSchema = NonstockFormSchema;
export type NonstockFormValues = Yup.InferType<typeof NonstockFormSchema>;
@@ -0,0 +1,391 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import {
NonstockFormSchema,
NonstockFormValues,
UpdateNonstockFormSchema,
} from '@/components/pages/master-data/nonstock/form/NonstockForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
Nonstock,
CreateNonstockPayload,
UpdateNonstockPayload,
} from '@/types/api/master-data/nonstock';
import { NonstockApi, SupplierApi, UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { flags } from '@/types/api/api-general';
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
interface NonstockFormProps {
type?: 'add' | 'edit' | 'detail';
initialValues?: Nonstock;
}
const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const router = useRouter();
const deleteModal = useModal();
const [nonstockFormErrorMessage, setNonstockFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createNonstockHandler = useCallback(
async (payload: CreateNonstockPayload) => {
const createNonstockRes = await NonstockApi.create(payload);
if (isResponseError(createNonstockRes)) {
setNonstockFormErrorMessage(createNonstockRes.message);
return;
}
toast.success(createNonstockRes?.message as string);
router.push('/master-data/nonstock');
},
[router]
);
const updateNonstockHandler = useCallback(
async (nonstockId: number, payload: UpdateNonstockPayload) => {
const updateNonstockRes = await NonstockApi.update(nonstockId, payload);
if (updateNonstockRes?.status === 'error') {
setNonstockFormErrorMessage(updateNonstockRes.message);
return;
}
toast.success(updateNonstockRes?.message as string);
router.refresh();
router.push('/master-data/nonstock');
},
[router]
);
const formikInitialValues = useMemo<NonstockFormValues>(() => {
return {
name: initialValues?.name ?? '',
uomId: initialValues?.uom_id ?? 0,
uom: initialValues?.uom
? {
value: initialValues?.uom.id,
label: initialValues?.uom.name,
}
: null,
supplierIds:
initialValues?.suppliers.map((supplier) => supplier.id) ?? [],
suppliers:
initialValues?.suppliers.map((supplier) => ({
value: supplier.id,
label: supplier.name,
})) ?? [],
flags: initialValues?.flags ?? [],
};
}, [initialValues]);
const formik = useFormik<NonstockFormValues>({
initialValues: formikInitialValues,
validationSchema:
type === 'edit' ? UpdateNonstockFormSchema : NonstockFormSchema,
onSubmit: async (values) => {
setNonstockFormErrorMessage('');
const nonstockPayload: CreateNonstockPayload = {
name: values.name,
uom_id: values.uomId,
supplier_ids: values.supplierIds as number[],
flags: values.flags as flags[],
};
switch (type) {
case 'add':
await createNonstockHandler(nonstockPayload);
break;
case 'edit':
await updateNonstockHandler(
initialValues?.id as number,
nonstockPayload
);
break;
}
},
});
const { setValues: formikSetValues } = formik;
// UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState('');
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({
search: uomSelectInputValue ?? '',
}).toString()}`;
const { data: uoms, isLoading: isLoadingUoms } = useSWR(
uomsUrl,
UomApi.getAllFetcher
);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({
value: uom.id,
label: uom.name,
}))
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true);
formik.setFieldValue('uom', val);
formik.setFieldTouched('uomId', true);
formik.setFieldValue('uomId', (val as OptionType)?.value);
};
// supplier
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({
search: supplierSelectInputValue ?? '',
}).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data
.filter((sup) => sup.category === 'BOP')
.map((supplier) => ({
value: supplier.id,
label: supplier.name,
}))
: [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('suppliers', true);
formik.setFieldValue('suppliers', val);
const supplierIds = (val as OptionType[]).map(
(supplier) => supplier.value as number
);
formik.setFieldTouched('supplierIds', true);
formik.setFieldValue('supplierIds', supplierIds);
};
const deleteNonstockClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await NonstockApi.delete(initialValues?.id as number);
deleteModal.closeModal();
toast.success('Successfully delete Nonstock!');
setIsDeleteLoading(false);
router.push('/master-data/nonstock');
};
const flagsChangeHandler = (val: OptionType | OptionType[] | null) => {
const formattedFlags = (val as OptionType[]).map(
(flag) => flag.value as string
);
formik.setFieldValue('flags', formattedFlags);
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
return (
<>
<section className='w-full max-w-xl'>
<header className='flex flex-col gap-4'>
<Button
href='/master-data/nonstock'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Nonstock'}
{type === 'edit' && 'Edit Nonstock'}
{type === 'detail' && 'Detail Nonstock'}
</h1>
</header>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full mt-8 flex flex-col gap-6'
>
<div className='flex flex-col gap-4'>
<TextInput
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.name && Boolean(formik.errors.name)}
errorMessage={formik.errors.name}
readOnly={type === 'detail'}
/>
<SelectInput
required
label='UOM'
value={formik.values.uom ?? undefined}
onChange={uomChangeHandler}
options={uomOptions}
onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms}
isError={formik.touched.uomId && Boolean(formik.errors.uomId)}
errorMessage={formik.errors.uomId as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
label='Supplier'
isMulti
value={formik.values.suppliers}
onChange={supplierChangeHandler}
options={supplierOptions ?? []}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={
formik.touched.suppliers && Boolean(formik.errors.suppliers)
}
errorMessage={formik.errors.suppliers as string}
isDisabled={type === 'detail'}
/>
<SelectInput
label='Flags'
isMulti
value={SUPPLIER_FLAG_OPTIONS.filter((opt) =>
formik.values.flags?.includes(opt.value)
)}
onChange={flagsChangeHandler}
options={SUPPLIER_FLAG_OPTIONS}
isError={formik.touched.flags && Boolean(formik.errors.flags)}
errorMessage={formik.errors.flags as string}
isDisabled={type === 'detail'}
isClearable
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={deleteNonstockClickHandler}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{type !== 'edit' && (
<Button
type='button'
color='warning'
href={`/master-data/nonstock/detail/edit/?nonstockId=${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>
{nonstockFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{nonstockFormErrorMessage}</span>
</div>
)}
</form>
</section>
{type !== 'add' && (
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Nonstock ini (${initialValues?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
)}
</>
);
};
export default NonstockForm;
@@ -0,0 +1,285 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
const RowOptionsMenu = ({
type = 'dropdown',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<ProductCategory, unknown>;
deleteClickHandler: () => void;
}) => {
return (
<div
tabIndex={type === 'dropdown' ? 0 : undefined}
className={cn(
{
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/master-data/product-category/detail/?productCategoryId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
<Button
href={`/master-data/product-category/detail/edit/?productCategoryId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='mdi:delete-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
);
};
const ProductCategoryTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: { search: '', nameSort: '' },
paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
});
const {
data: productCategories,
isLoading,
mutate: refreshProductCategories,
} = useSWR(
`${ProductCategoryApi.basePath}${getTableFilterQueryString()}`,
ProductCategoryApi.getAllFetcher
);
const deleteModal = useModal();
const [selectedProductCategory, setSelectedProductCategory] = useState<
ProductCategory | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const productCategoryColumns: ColumnDef<ProductCategory>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'code',
header: 'Code',
},
{
accessorKey: 'name',
header: 'Nama',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProductCategory(props.row.original);
deleteModal.openModal();
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
];
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductCategoryApi.delete(selectedProductCategory?.id as number);
refreshProductCategories();
deleteModal.closeModal();
toast.success('Successfully delete Product Category!');
setIsDeleteLoading(false);
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='flex flex-row'>
<Button href='/master-data/product-category/add' color='primary'>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah Product Category
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Product Category'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'max-w-28' }}
/>
</div>
</div>
<Table<ProductCategory>
data={
isResponseSuccess(productCategories) ? productCategories?.data : []
}
columns={productCategoryColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(productCategories)
? productCategories?.meta?.page
: 0
}
totalItems={
isResponseSuccess(productCategories)
? productCategories?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(productCategories) &&
productCategories?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
}}
/>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Product Category ini (${selectedProductCategory?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default ProductCategoryTable;

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