mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Compare commits
916 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77a7b6ccf8 | |||
| 0ac14fe342 | |||
| 00eed01cea | |||
| 6702dd7dc6 | |||
| a526772e02 | |||
| b486d25a8b | |||
| b73b452621 | |||
| 4f4787e088 | |||
| f32024d19a | |||
| 3b666a924f | |||
| 021df11600 | |||
| d306fad40c | |||
| ebc12638ff | |||
| 437dd75934 | |||
| e1c0701629 | |||
| 96a5eb1be5 | |||
| 7add41ea5a | |||
| 548dfd19e5 | |||
| f0a6dd4a5a | |||
| 7159114733 | |||
| e300a60b5a | |||
| 4cbd83a6fa | |||
| b39202111e | |||
| a891608d13 | |||
| aff05c6b1a | |||
| 70eac011f3 | |||
| 10e843aebf | |||
| a24f51dad3 | |||
| 9245285fe2 | |||
| eb1337292b | |||
| aa114c164b | |||
| 0f9849c0ac | |||
| 36b167dafb | |||
| 1002c6c437 | |||
| 25352659f3 | |||
| dfac7f84ff | |||
| c099314bdb | |||
| 404019f181 | |||
| 8afd8c6382 | |||
| ef4ad07547 | |||
| 31aa8e7652 | |||
| 994967e940 | |||
| 90eef08f9b | |||
| def2167803 | |||
| bf834cf79b | |||
| 470add1563 | |||
| 114658f198 | |||
| d01bfe33a8 | |||
| 47ba16b20a | |||
| d6c6211937 | |||
| 536b1c5b01 | |||
| f84fcb78b8 | |||
| aec5c89979 | |||
| f46a0610f5 | |||
| d879acc001 | |||
| 8516929056 | |||
| 595f2b5e9b | |||
| 6a4e8776bd | |||
| ec16c6c47e | |||
| 2b2dd0a026 | |||
| a8c12d0c92 | |||
| 334bd08e60 | |||
| ddd9a3d2da | |||
| 2b1d5290f3 | |||
| 935588b30e | |||
| 677025b4a2 | |||
| 0da9f9d651 | |||
| c752cad057 | |||
| cdfb59a70b | |||
| 91fcbffab6 | |||
| 33e3b86b82 | |||
| a012707bae | |||
| 00bc644ea9 | |||
| 126346dc52 | |||
| 777b06c690 | |||
| 1f96100390 | |||
| 7e5898a253 | |||
| 86651d3f3f | |||
| ebd3e14f0e | |||
| e9a1f4298f | |||
| cc8f258c41 | |||
| f60a07bc5b | |||
| 6dc93b1065 | |||
| 76c68d0d79 | |||
| f1ed22ff22 | |||
| d478ef5e22 | |||
| 6643fe5a60 | |||
| 7a6b003cb9 | |||
| e6cee4a93a | |||
| c61ef5471b | |||
| c9c618e3f8 | |||
| ceb7cb6b2d | |||
| f765895988 | |||
| b1715172db | |||
| d84b2583d8 | |||
| f38cebc0d9 | |||
| 7951754722 | |||
| 4fc689898f | |||
| 69d7f65b76 | |||
| b626c2805f | |||
| 97c16ce596 | |||
| 88e3ec7bbc | |||
| 3ce30115f8 | |||
| 4fdfe63dc9 | |||
| badb1e141a | |||
| c894f26d18 | |||
| 3b9599d169 | |||
| 9a9a9c0cb5 | |||
| b69126ed84 | |||
| 91b0bf7c27 | |||
| 1b2a45f9ac | |||
| cb62416552 | |||
| d2781b0a89 | |||
| 325f3f1bd8 | |||
| 346c731c42 | |||
| 61766d3255 | |||
| 0898892f15 | |||
| 3dd4a9cebc | |||
| 549e15499c | |||
| a4a07f2ce9 | |||
| 6930696692 | |||
| 7df9559f35 | |||
| eeeb0a404c | |||
| b3c4a438ad | |||
| a9e6f7155e | |||
| 3d94474d7c | |||
| 4872a53a25 | |||
| 662dec38bc | |||
| 9f0cefe91c | |||
| 3cb6bfcf52 | |||
| 7d4869fbdc | |||
| 4ae36ee3f0 | |||
| 21acb09f0c | |||
| f17bc1493b | |||
| 6943cd3903 | |||
| 14b63dd0f1 | |||
| db4d9ad38c | |||
| 06dd9a3609 | |||
| 5ba58c92d4 | |||
| d44de5a363 | |||
| b70ae164e1 | |||
| f89236bb5e | |||
| 4759a82034 | |||
| 866bd90b3d | |||
| b0dfc7f31c | |||
| 561a9fe186 | |||
| dbdfd2c50b | |||
| afc102e618 | |||
| 7736fce5bb | |||
| 04dcf110a3 | |||
| 53bca6170f | |||
| 20e27dccc1 | |||
| 965dc01e86 | |||
| 64a0a9c8a8 | |||
| 0ed30e184b | |||
| 13205ca80a | |||
| 0d6e229ee5 | |||
| 319afa3faf | |||
| 6da6cf699f | |||
| ca830f8e3d | |||
| 22be41058f | |||
| c4debcecce | |||
| 6fdff6706d | |||
| d5b4111ae4 | |||
| 2df86e7be8 | |||
| 6efe44ba55 | |||
| c766f53753 | |||
| 38dfeec892 | |||
| cf8ed9ccad | |||
| 90c61cbdf6 | |||
| bfcdb9883d | |||
| b0a1b837d0 | |||
| 09ae619829 | |||
| 59f4528841 | |||
| d049f6c34f | |||
| 8b7ed9e46b | |||
| e6172be81e | |||
| 324b9b14ef | |||
| a10c20394d | |||
| ff2d53a0b7 | |||
| 42d8896ab7 | |||
| 50d499005d | |||
| b421bc48d0 | |||
| f7986149e8 | |||
| 309a9ecc86 | |||
| d30979f5cd | |||
| 43cd3abe02 | |||
| 770f363c60 | |||
| 88c6c863e7 | |||
| ba84e718cb | |||
| fed2bf7878 | |||
| 6f90bd604a | |||
| fa199e4879 | |||
| 46c06ea548 | |||
| fd868eaa0c | |||
| 8dfccf25d8 | |||
| 5d8dfca3b5 | |||
| aee0ad8a20 | |||
| 2fa086bb32 | |||
| 0af612703a | |||
| f22c4e4798 | |||
| 841aadc107 | |||
| f31a80340b | |||
| 727fd2ad43 | |||
| a4275f4b66 | |||
| 9ac5e0ee76 | |||
| 24499d110a | |||
| c24aebe02d | |||
| fd32b55ad9 | |||
| d2b19cbd7b | |||
| 2c29cffa45 | |||
| 476cf2fa29 | |||
| 1797498df2 | |||
| f7d1beffcf | |||
| ac6e7c6d36 | |||
| d7ef86e24b | |||
| 3dff72b0f0 | |||
| eea76e77a8 | |||
| 11f9a685a8 | |||
| c863ebc2af | |||
| e8aea0a27e | |||
| 79cf777b11 | |||
| 203782c258 | |||
| 0a0a1a23f9 | |||
| f9f4c5b67c | |||
| 98107373e8 | |||
| 9838cf347b | |||
| f183ee5c7a | |||
| 2ab2cd6d99 | |||
| 1571d79685 | |||
| 5e3648b385 | |||
| 62b05bf9c0 | |||
| 7579cd5533 | |||
| 174e5ed1a3 | |||
| c6e9e98ce1 | |||
| 9562ce7b40 | |||
| 57fa67c05a | |||
| eadb08879d | |||
| 50b8b77f22 | |||
| 045913d05f | |||
| b53c8b99a0 | |||
| f23d369e02 | |||
| d6b8b10183 | |||
| 1c77deeee7 | |||
| 6a3d2c0dcd | |||
| b1f4b4dc4b | |||
| a518895096 | |||
| 046fb74cab | |||
| 00f09364b1 | |||
| 02c5cddc94 | |||
| 9f079c1e52 | |||
| c32074d72c | |||
| 7d46e3dc2e | |||
| e7b53efa4b | |||
| aebb9a0b28 | |||
| 4ec62c936e | |||
| f7ae1f835f | |||
| 3769309ce3 | |||
| 15b3151c5f | |||
| b0707db551 | |||
| 1a8d794a66 | |||
| 1b7e8a342f | |||
| f08fae4f77 | |||
| 6cd09a413d | |||
| 84eb34a9dd | |||
| 37317ed95c | |||
| 201c9249cc | |||
| 46dfacae23 | |||
| d856b35e24 | |||
| 28c94e3e1d | |||
| a1e8f582ba | |||
| f3f552bd16 | |||
| 5af00faa32 | |||
| f0dcb6b8ca | |||
| e32b9ddcb2 | |||
| 57ef4109f7 | |||
| ef3611e7fc | |||
| 0090961ec0 | |||
| 640cf26970 | |||
| d1d0692e2e | |||
| 6b13794ee5 | |||
| 8e88355894 | |||
| 1b98e5d4d8 | |||
| d8daf09844 | |||
| 0bb9aee139 | |||
| 306b8d3bf3 | |||
| 2bf764a05c | |||
| 6c3285f624 | |||
| afb79b0589 | |||
| 4f571f1c16 | |||
| 81ca60a09b | |||
| 23453eb8f5 | |||
| 3dc32da834 | |||
| f089492830 | |||
| 3412994d15 | |||
| 6eaa92dfd4 | |||
| 8d668429e1 | |||
| 8d1a3b665e | |||
| 7b99bae197 | |||
| dad169d854 | |||
| f985c041e4 | |||
| 5326eff293 | |||
| d66bd8c606 | |||
| ea5ad20684 | |||
| 23ee8828f0 | |||
| 0dd2edfe01 | |||
| 6edc278bdf | |||
| f81c49becb | |||
| 03a9451fc8 | |||
| cc0b051a0a | |||
| 865438e3fb | |||
| d39b71e759 | |||
| 5e6b03ef08 | |||
| c291ba3246 | |||
| ab2e7db9d0 | |||
| 13c1a82142 | |||
| 6185fafb57 | |||
| 2ab7c10d5d | |||
| bc6ebcfeda | |||
| 10fb9fc990 | |||
| 28639516d5 | |||
| 2bf0f2874e | |||
| a81a61135f | |||
| d2e88c2061 | |||
| 8f4f3d93b8 | |||
| 7daca04cc1 | |||
| 2c5168badf | |||
| 73f4b486c0 | |||
| ed7ee1a268 | |||
| 4e7b91a7b4 | |||
| 7c0581728e | |||
| 52cb440cb3 | |||
| 7c64870fed | |||
| 7290f242f4 | |||
| 9be09ae281 | |||
| 32088b916f | |||
| f51236fcfc | |||
| c385c42c8f | |||
| 02dc624036 | |||
| 4e5f9c710c | |||
| 2712821f4e | |||
| d6849a48d2 | |||
| 2e44371c6c | |||
| 98ae56a1aa | |||
| 7fcab4d295 | |||
| 550bcc426b | |||
| 844ac01b70 | |||
| 9ef232bac5 | |||
| 34ec650a01 | |||
| 1d27781c02 | |||
| e81c0a3baf | |||
| be0bdcd299 | |||
| 7e73c99074 | |||
| 0f86ba0f2d | |||
| 10a37fde75 | |||
| 6ad1a3349b | |||
| 3bb5d5e5a5 | |||
| 3279fb30ce | |||
| 34eae71b44 | |||
| 4f168b51c7 | |||
| ded1cc1f62 | |||
| 39f2fc48a8 | |||
| a72b22da6e | |||
| dc4e569453 | |||
| 17a6cee1e3 | |||
| 5e32724d40 | |||
| cd3a5ad441 | |||
| 11bd8b27b5 | |||
| 9a1a6a7e41 | |||
| d02f919b76 | |||
| 4529ee50e3 | |||
| cd42bd6bc0 | |||
| 4ed1e4f8b5 | |||
| ea7f8a68f4 | |||
| 11a63f76b7 | |||
| cd41d5daab | |||
| 9f2fcbf154 | |||
| 70d9b4d8ed | |||
| 39f70bd71b | |||
| 817f8a7010 | |||
| 2276df2790 | |||
| 8ec76af012 | |||
| 9f0dc8c644 | |||
| 2d0c8dbd3f | |||
| 8224dbf8ec | |||
| 6e4462e217 | |||
| b1ccad081d | |||
| c0a818af7e | |||
| e0371b0884 | |||
| 8a6f78ef84 | |||
| b2c09bb7c7 | |||
| c550922974 | |||
| 432d837aaf | |||
| b24fb54856 | |||
| f37eea687a | |||
| 77b05c6440 | |||
| 731bec5a94 | |||
| 6ea25aa3b1 | |||
| d4f4505405 | |||
| bd653851e2 | |||
| c8f47c741a | |||
| 78486be3ea | |||
| fe04bf5692 | |||
| 3c29b8bc77 | |||
| 45d65024db | |||
| fd2077c68b | |||
| 819b709f7e | |||
| 549a710a8d | |||
| ec8ae7561d | |||
| 5f68c05acc | |||
| b9b349aa7a | |||
| 0a447f93c1 | |||
| 6ec6323bbc | |||
| c44e63bd2b | |||
| 500c30c2bc | |||
| 507c4005af | |||
| d49bca1d40 | |||
| e4d75dad68 | |||
| 4f22024c82 | |||
| 751c27b73e | |||
| 0d77aa4a5f | |||
| 6fde6b180a | |||
| 663c1dea14 | |||
| 4aab54981e | |||
| 04c987b86b | |||
| 800739bd4f | |||
| e0ee846106 | |||
| 84b49d2ac6 | |||
| ec95ddbddd | |||
| 6d2057842d | |||
| 1843a47d59 | |||
| 9e0d3e2bbf | |||
| e6a38c3f65 | |||
| f58cb43801 | |||
| 517e8c758c | |||
| 97c5917401 | |||
| 4be719b9d8 | |||
| 31a9828661 | |||
| 580c357667 | |||
| 1152cd2bef | |||
| f1227c9dcb | |||
| 5f3c3be1f3 | |||
| ae00f49e64 | |||
| d9322cc17d | |||
| f5f154883b | |||
| 8c21883aa9 | |||
| 4ddd1dc8e3 | |||
| 879702d31d | |||
| 8c95dc8327 | |||
| f0eb3fcf52 | |||
| df6c1ae49d | |||
| 42a56a08d7 | |||
| 871f0403ad | |||
| b57a0fcc90 | |||
| 6ef9c1338f | |||
| ea4dd29bbf | |||
| d591c89cac | |||
| 22d24af41c | |||
| 6ed7dcfa6d | |||
| dda29e10d1 | |||
| 20d124504b | |||
| b9c1989cae | |||
| 5fae7752f2 | |||
| 9d9b9d93db | |||
| f41899dbc9 | |||
| 9e5d878e82 | |||
| 035f187bac | |||
| cb78ec4990 | |||
| 3a2fac013e | |||
| 3b2e11fd41 | |||
| 414d617341 | |||
| 0774200aa5 | |||
| 5dd64b9907 | |||
| 8fc5d42bb8 | |||
| 36ff6d04ee | |||
| f23a0144b0 | |||
| de63b6721a | |||
| a200dac23c | |||
| 09dd907f88 | |||
| 33b8d0a8b0 | |||
| 398282b3bf | |||
| 035482accc | |||
| fcfd2fb576 | |||
| 2c28d0a831 | |||
| 907afbb062 | |||
| addfaff692 | |||
| ecdbb764d5 | |||
| a3be3de338 | |||
| 9e895af62a | |||
| 1f9992c1c8 | |||
| 574fb3b371 | |||
| 4643a39c3e | |||
| 88b8767ca4 | |||
| de19cc5de2 | |||
| b9dad3094c | |||
| a4b9b3fd2f | |||
| b91a199d13 | |||
| ff427d13cc | |||
| 8295943b82 | |||
| a3169d582d | |||
| bf16d259bd | |||
| 5ae299a4b5 | |||
| c840f881bb | |||
| 6f16cf6deb | |||
| 5b4bc136f2 | |||
| dd6c6263e7 | |||
| 346d655406 | |||
| 5ff132070c | |||
| 398a09bf3b | |||
| 4e1315a027 | |||
| dcf2acc799 | |||
| 67a3ce2906 | |||
| 1d28e80b66 | |||
| 9dae6f1e95 | |||
| 5d03b68576 | |||
| 4bc9926356 | |||
| ea32056ca8 | |||
| ddfdfe4d91 | |||
| 7ea16d6a8a | |||
| 206d6c0b4e | |||
| 382721059a | |||
| 6a71828167 | |||
| a5e79570c5 | |||
| 804aa700d3 | |||
| 982a5d0d11 | |||
| 478e9eb541 | |||
| 9e0631a415 | |||
| 18ca7d8a59 | |||
| eb8a1567c6 | |||
| a0e63ea2d4 | |||
| 1ac35691ff | |||
| f9aa254c18 | |||
| c8effe4473 | |||
| c230c8000b | |||
| a7267370a0 | |||
| daddebc0a6 | |||
| 856d1f5c0c | |||
| da5a577fde | |||
| c36d1ee153 | |||
| 7259de8b14 | |||
| 9e576cf444 | |||
| d7b828cb47 | |||
| f757e5f6ba | |||
| 7f694c7298 | |||
| 5326fc918a | |||
| 6658312427 | |||
| de73c626b1 | |||
| faaa10b74b | |||
| d66eaf08c0 | |||
| a6a6ff9f72 | |||
| 5a21a3b44c | |||
| 00e0126e42 | |||
| 2f23755510 | |||
| 996e132660 | |||
| e3eda4f5e4 | |||
| 7cc616ff41 | |||
| 0b75d68494 | |||
| 83224e046b | |||
| 096a8d394e | |||
| 11bf6ad760 | |||
| c8a834f84a | |||
| ead5ba759d | |||
| 8ceca2cc59 | |||
| 8dc23f83cd | |||
| 57f53b0a04 | |||
| 7e0aa4f790 | |||
| 2fe4ec981c | |||
| cf41fbfdaf | |||
| 86cef78a12 | |||
| fa63bd8ff9 | |||
| d9b41a6760 | |||
| c9cf33f1ad | |||
| 33d8d2aa2a | |||
| 61d85154fd | |||
| 466ea47121 | |||
| 3a35c72e06 | |||
| 09d36f504b | |||
| b9b7e45bc7 | |||
| e49c247f02 | |||
| b8c6f94db8 | |||
| 5def3c9f17 | |||
| 447b8067f7 | |||
| 4a8f2b1e1d | |||
| 36389bae2a | |||
| d001b05c4e | |||
| 20494657c6 | |||
| 2d8e479b6c | |||
| fef7f0e29b | |||
| 81d242bd1d | |||
| 1be596921a | |||
| 8fb1ccbdce | |||
| 85fddcb19a | |||
| 915e68f755 | |||
| 87adbf8547 | |||
| e8492f87ba | |||
| ceae338c73 | |||
| fa7824224c | |||
| 6b30457ec2 | |||
| 843fa6ee7a | |||
| a935ffd9f5 | |||
| 1de98db4ba | |||
| f844c9ff2c | |||
| 83fc92d48b | |||
| 4edd4f1285 | |||
| cc3765abcd | |||
| 3497a6346c | |||
| 69b4ca455e | |||
| 320bc52244 | |||
| 40f2d0ba93 | |||
| 481a643b3c | |||
| 9b2d98f7ce | |||
| 3e8c29df64 | |||
| 6155929e14 | |||
| 7c9f68d3a3 | |||
| 918e85e0cc | |||
| bb80e9e9c6 | |||
| 80fd75dfc1 | |||
| e1b562c175 | |||
| 9365320b03 | |||
| e515438312 | |||
| 530ef4982d | |||
| a8c3b1a66f | |||
| 62d4d7b7db | |||
| 57df2e9aed | |||
| 9ea86fe5c3 | |||
| ecf1677c27 | |||
| c1e075b1ff | |||
| c70cfbd450 | |||
| 4cc41c0167 | |||
| 730b7903a7 | |||
| 78efd587be | |||
| 3d91c12874 | |||
| 12e6d60745 | |||
| 01313f0b09 | |||
| 9f521a6a08 | |||
| 43afc5781c | |||
| 0f7f4e891c | |||
| b02b458034 | |||
| 304084ea2c | |||
| 204a1098a3 | |||
| c04cd29ac7 | |||
| 224f7ddeea | |||
| d17c11e2f2 | |||
| f58b03ba0e | |||
| c9544e1bd0 | |||
| d348cee4e6 | |||
| 1560908101 | |||
| 874bc27e4c | |||
| be238779a4 | |||
| aea39a878a | |||
| 84e562e22c | |||
| 8db7b6d5e9 | |||
| 5c8bc4fc6e | |||
| 9ba3fa1b6c | |||
| 6f18c58042 | |||
| 0d8e642b4e | |||
| 615d4d5ffe | |||
| 2a00da0298 | |||
| 68437b3b7e | |||
| 31b2a5a548 | |||
| 4d997256ad | |||
| 7ea9e10ad2 | |||
| 245da2595c | |||
| 45f1e923b7 | |||
| ebe752b27b | |||
| 3c3c2345c7 | |||
| 3f78cfdb63 | |||
| 69eaae6d43 | |||
| 65b60cc464 | |||
| 5c9332537c | |||
| 63c2a240d2 | |||
| fd2e1f8b96 | |||
| fdb3e0481a | |||
| 9bc632c286 | |||
| 81f98c5f06 | |||
| 0983f154d2 | |||
| bd4c51cb04 | |||
| 9c09395677 | |||
| 67b5187d39 | |||
| 5de5dcffc0 | |||
| 51ff739926 | |||
| 238edfeca2 | |||
| bd8d121113 | |||
| 38b91a57f0 | |||
| d0abc0e9ff | |||
| 4262e8e286 | |||
| 48c163c1cd | |||
| 3c03494bd3 | |||
| 3dd36b8248 | |||
| 12698004e1 | |||
| a0ca8e8f69 | |||
| 69206d4524 | |||
| a73f9a1acd | |||
| 4ea56f2e18 | |||
| 48649df409 | |||
| c53f9352be | |||
| df632526d2 | |||
| 4ec455b3b7 | |||
| 4f4fd3e6b7 | |||
| 0d7dd0a110 | |||
| 9bf4fd585d | |||
| a77a360410 | |||
| 9628ee88ad | |||
| 4356bd8803 | |||
| cbf1660da5 | |||
| 37f59f9470 | |||
| 6e3b25eb98 | |||
| f939f4b0fb | |||
| 720ff2128f | |||
| 280fffe6a5 | |||
| 6340a5e519 | |||
| d4fc0b4a4f | |||
| 4f595c7cad | |||
| 3826b8ea53 | |||
| 5cc82f1615 | |||
| cfaac14820 | |||
| b39d1f5c2e | |||
| 30ab48e426 | |||
| 88c640df18 | |||
| 017b081832 | |||
| 83d76f7de4 | |||
| 9af140e58d | |||
| 654aa50cc7 | |||
| 814e8db1ba | |||
| d1883654bc | |||
| 2c6ad71fd3 | |||
| 6c31d933b0 | |||
| b806c0f0a1 | |||
| a073488c2c | |||
| 7efb2a4dbb | |||
| 01d1ed8f0d | |||
| aed58ef10c | |||
| f105852a07 | |||
| 21d6fc8579 | |||
| eea1fcb513 | |||
| 312580a7fc | |||
| 7c4d5e68fa | |||
| b74e43c483 | |||
| dbff1bda3d | |||
| 244c564f06 | |||
| 757e0435ac | |||
| 46d70e36dd | |||
| 0cc9d0e94e | |||
| d7199fad53 | |||
| 8c2683c440 | |||
| 9c7033b53a | |||
| 07bfe0a20b | |||
| b9e69b243f | |||
| 8dec4915a2 | |||
| 7be32326a9 | |||
| c416fdbdaa | |||
| 270e8ff0c6 | |||
| 64abc5001d | |||
| f48cfca650 | |||
| a116f7ca66 | |||
| 429f5ffb62 | |||
| eed142a85f | |||
| 48f228de1c | |||
| c92abfc9ab | |||
| 7e999b2e34 | |||
| e90c7d993c | |||
| 99fbcaaea3 | |||
| 865b0b3d8f | |||
| a4c83f99a7 | |||
| e23b698fc7 | |||
| 294c971fea | |||
| 8a8128a692 | |||
| 649dd70ea7 | |||
| 44b9210ccc | |||
| e3f90a49d0 | |||
| a1679ba5ff | |||
| 8a7149c123 | |||
| f87854ed07 | |||
| 1ef7130661 | |||
| 066c356d4f | |||
| 8c7640eb9c | |||
| 8f5dd1851a | |||
| 489815ecaf | |||
| f9dfe7b27f | |||
| 6a926f881d | |||
| 68dd5b1121 | |||
| 5a3c7d71b0 | |||
| 5ad61d483a | |||
| 1567a4016f | |||
| 8b8702b1b8 | |||
| b039ec832b | |||
| abf2735b86 | |||
| a26099b507 | |||
| d7384752a0 | |||
| dfecef2e0c | |||
| 80fd8bb7ba | |||
| 44b9f94cec | |||
| e3b3f5ccdc | |||
| 33691f45bb | |||
| 2c72c44be4 | |||
| 98dfd4564c | |||
| a795d78c80 | |||
| 8a0adf847e | |||
| 8e80d668fa | |||
| a45de4fb13 | |||
| 6ee5bc3f1b | |||
| 012fe800bc | |||
| c3835d5128 | |||
| 7c4bd81364 | |||
| 545af8267a | |||
| 2e6a724b2f | |||
| 305b8e5005 | |||
| 5deca5739f | |||
| b464432581 | |||
| 512ad5175e | |||
| a7d884b5f0 | |||
| ce75eb25d7 | |||
| c7911f01f2 | |||
| 68874a1c14 | |||
| 7cc2a31745 | |||
| f5663b82aa | |||
| 3a7f1f4812 | |||
| 32ffc1f14c | |||
| 58fb9b0c08 | |||
| 3569955e7f | |||
| 7df743ebf5 | |||
| 86a0faaa52 | |||
| df3f342214 | |||
| c3c1bbbe96 | |||
| dc0fd7a3ed | |||
| 5782abb531 | |||
| 2d1cabb86b | |||
| b362fd1748 | |||
| 3411aa9b1b | |||
| 1f29e3cb50 | |||
| b671de1336 | |||
| 090a3183f7 | |||
| 17865d733d | |||
| 5be67ef01c | |||
| 7f326bedd4 | |||
| c350bc0be2 | |||
| 6f7627ac92 | |||
| 1ae5c1bd64 | |||
| 5bb366026d | |||
| 9888dc4356 | |||
| 7615daa22a | |||
| 435cc0aedc | |||
| d189252551 | |||
| d85cf29193 | |||
| 84ff5e178b | |||
| 72840e2193 | |||
| ea2ada8224 | |||
| b97cc39854 | |||
| 195bbbe449 | |||
| 375b50b646 | |||
| a5c71ff8ce | |||
| e09074eed0 | |||
| ffbf886718 | |||
| b3f7b8a3c5 | |||
| e407410c4a | |||
| 341cb42452 | |||
| 99b9df27a7 | |||
| 27c867036f | |||
| c9552dec2d | |||
| aad24c3c58 | |||
| ff1493b520 | |||
| 4ff1649991 | |||
| 4fe53f364a | |||
| 85fdb4f7dd | |||
| 885e4250fd | |||
| eaf118845c | |||
| 30db7ee95d | |||
| 5869e0434b | |||
| f205c66509 | |||
| 46e072bbcf | |||
| c31b284cf4 | |||
| bac3f30ce3 | |||
| be725d42c3 | |||
| b37c3f87b0 | |||
| ae4c17b391 | |||
| 48dd6d7218 | |||
| cee3d4ba90 | |||
| a8d7fdc30d | |||
| 2bb2da74e6 | |||
| fd024fdb8f | |||
| 79a89ea193 | |||
| 611655e408 | |||
| 702943c55c | |||
| 075d945a59 | |||
| 7d9a88cf3b | |||
| b095208fae | |||
| c69d9dd605 | |||
| a1d0c7b331 | |||
| e0a8514814 | |||
| 949761d59d | |||
| 15ced14e20 | |||
| 492efb18e2 | |||
| 8ea29579ec | |||
| dc6b0eaec6 | |||
| 1a4a05308f | |||
| ba40bbb1d3 | |||
| 647b002065 | |||
| 991a594ee1 | |||
| 3b846bf11c | |||
| 3e07316678 | |||
| 411c2586f5 | |||
| 3a87b039bf | |||
| 50559caf52 | |||
| 8fbe6aa148 | |||
| 873a4b308d | |||
| f0ec758d7f | |||
| 88878f7613 | |||
| 31f758d680 | |||
| 9eba5ffeca | |||
| 6b5838b5aa | |||
| c76f3a3715 | |||
| 48435a9cbb | |||
| 2ace95a0db | |||
| 892bb19dfd | |||
| 7a76719547 | |||
| 4b6144d0b4 |
@@ -42,3 +42,6 @@ next-env.d.ts
|
||||
|
||||
# idea
|
||||
.idea
|
||||
|
||||
# claude
|
||||
.claude
|
||||
|
||||
+48
-29
@@ -2,6 +2,17 @@ stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
# ==========================================================
|
||||
# ✅ Global defaults
|
||||
# ==========================================================
|
||||
default:
|
||||
tags:
|
||||
- server-development-biznet
|
||||
interruptible: true
|
||||
|
||||
# ==========================================================
|
||||
# 🏗️ Build Template
|
||||
# ==========================================================
|
||||
.build_template: &build_template
|
||||
stage: build
|
||||
image: node:20-alpine
|
||||
@@ -39,6 +50,9 @@ stages:
|
||||
- out/
|
||||
expire_in: 1 week
|
||||
|
||||
# ==========================================================
|
||||
# 🚀 Deploy Template
|
||||
# ==========================================================
|
||||
.deploy_template: &deploy_template
|
||||
stage: deploy
|
||||
image:
|
||||
@@ -73,8 +87,8 @@ stages:
|
||||
|
||||
if [ "$CI_COMMIT_BRANCH" = "development" ]; then
|
||||
ENVIRONMENT_NAME="WEB-LTI-DEV"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "master" ]; then
|
||||
ENVIRONMENT_NAME="WEB-LTI-PROD"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "staging" ]; then
|
||||
ENVIRONMENT_NAME="WEB-LTI-STAGING"
|
||||
else
|
||||
ENVIRONMENT_NAME="UNKNOWN"
|
||||
fi
|
||||
@@ -82,11 +96,11 @@ stages:
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
COLOR=3066993
|
||||
TITLE="✅ Deployment ${ENVIRONMENT_NAME} Succeeded"
|
||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."
|
||||
DESC="Deployment job on branch \${CI_COMMIT_REF_NAME}\ completed successfully."
|
||||
else
|
||||
COLOR=15158332
|
||||
TITLE="❌ Deployment ${ENVIRONMENT_NAME} Failed"
|
||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` encountered issues."
|
||||
DESC="Deployment job on branch \${CI_COMMIT_REF_NAME}\ encountered issues."
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
@@ -114,7 +128,9 @@ stages:
|
||||
|
||||
curl -sS -H "Content-Type: application/json" -d @payload.json "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
# ====== DEVELOPMENT (Branch development) ======
|
||||
# ==========================================================
|
||||
# ==== DEVELOPMENT (Branch development) ======
|
||||
# ==========================================================
|
||||
build:dev:
|
||||
<<: *build_template
|
||||
rules:
|
||||
@@ -122,11 +138,10 @@ build:dev:
|
||||
environment:
|
||||
name: development
|
||||
variables:
|
||||
# NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
|
||||
# NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
|
||||
NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
|
||||
deploy:dev:
|
||||
<<: *deploy_template
|
||||
@@ -140,27 +155,31 @@ deploy:dev:
|
||||
environment:
|
||||
name: development
|
||||
url: https://dev-lti-erp.mbugroup.id
|
||||
|
||||
# ====== PRODUCTION ======
|
||||
# build:production:
|
||||
# <<: *build_template
|
||||
# rules:
|
||||
# # pilih salah satu: pakai branch master ATAU pakai tags rilis
|
||||
# - if: '$CI_COMMIT_BRANCH == "master"'
|
||||
# # - if: '$CI_COMMIT_TAG' # kalau mau rilis via tag, uncomment ini dan hapus baris di atas
|
||||
# environment:
|
||||
# name: production
|
||||
|
||||
# deploy:production:
|
||||
# <<: *deploy_template
|
||||
# needs: ["build:production"]
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_BRANCH == "master"'
|
||||
# # - if: '$CI_COMMIT_TAG' # selaras dengan rule di build:production
|
||||
# variables:
|
||||
# S3_BUCKET: "lti-erp.mbugroup.id"
|
||||
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
|
||||
# environment:
|
||||
# name: production
|
||||
|
||||
# ==========================================================
|
||||
# ====== STAGING (Branch staging) ======
|
||||
# ==========================================================
|
||||
build:staging:
|
||||
<<: *build_template
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
environment:
|
||||
name: staging
|
||||
variables:
|
||||
NEXT_PUBLIC_LTI_URL: 'https://stg-lti-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
|
||||
deploy:staging:
|
||||
<<: *deploy_template
|
||||
needs: ['build:staging']
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
variables:
|
||||
S3_BUCKET: 'stg-lti-erp.mbugroup.id'
|
||||
AWS_REGION: 'ap-southeast-3'
|
||||
CLOUDFRONT_DISTRIBUTION_ID: 'E2V6PPO1AUIU7H'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://stg-lti-erp.mbugroup.id
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
npm run format
|
||||
npm run lint
|
||||
npm run build
|
||||
npx tsc --noEmit
|
||||
@@ -3,6 +3,7 @@ import type { NextConfig } from 'next';
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'export',
|
||||
images: { unoptimized: true },
|
||||
trailingSlash: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+3466
-34
File diff suppressed because it is too large
Load Diff
+20
-5
@@ -15,20 +15,35 @@
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.12.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"formik": "^2.4.6",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "15.5.7",
|
||||
"react": "19.1.0",
|
||||
"next": "15.5.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.2",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dom": "^19.1.2",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-resizable-panels": "2.1.7",
|
||||
"react-select": "^5.10.2",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-debounce": "^10.0.6",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@@ -39,9 +54,9 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"daisyui": "^5.1.12",
|
||||
"daisyui": "^5.5.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"eslint-config-next": "^15.5.7",
|
||||
"husky": "^9.1.7",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ClosingDetail from '@/components/pages/closing/ClosingDetail';
|
||||
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { FlockApi } from '@/services/api/master-data';
|
||||
import { ProjectFlockApi } from '@/services/api/production/project-flock';
|
||||
import { ProjectFlockKandangApi } from '@/services/api/production';
|
||||
|
||||
const ClosingDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const closingId = searchParams.get('closingId');
|
||||
const kandangId = searchParams.get('kandangId'); // project flock kandang ID
|
||||
|
||||
const { data: closing, isLoading: isLoadingClosing } = useSWR(
|
||||
closingId,
|
||||
(id: number) => ClosingApi.getGeneralInfo(id)
|
||||
);
|
||||
|
||||
// WORKAROUND - get flock data from closing ID
|
||||
const { data: projectData, isLoading: isLoadingProject } = useSWR(
|
||||
`flock-${closingId}`,
|
||||
() => ProjectFlockApi.getSingle(Number(closingId))
|
||||
);
|
||||
// WORKAROUND - get kandang data from closing ID
|
||||
const { data: kandangData, isLoading: isLoadingKandang } = useSWR(
|
||||
kandangId ? `kandang-${closingId}-${kandangId}` : null,
|
||||
() => ProjectFlockKandangApi.getSingle(Number(kandangId))
|
||||
);
|
||||
|
||||
const { data: salesData, isLoading: isLoadingSales } = useSWR(
|
||||
closingId ? `sales-${closingId}` : null,
|
||||
() => ClosingApi.getPenjualan(Number(closingId))
|
||||
);
|
||||
|
||||
const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR(
|
||||
closingId ? `hpp-ekspedisi-${closingId}` : null,
|
||||
() => ClosingApi.getHppEkspedisi(Number(closingId))
|
||||
);
|
||||
|
||||
if (!closingId) {
|
||||
router.back();
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoadingClosing && (!closing || isResponseError(closing))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
isLoadingClosing ||
|
||||
isLoadingSales ||
|
||||
isLoadingHppEkspedisi ||
|
||||
isLoadingProject ||
|
||||
isLoadingKandang;
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoading && <span className='loading loading-spinner loading-xl' />}
|
||||
|
||||
{!isLoading && isResponseSuccess(closing) && (
|
||||
<ClosingDetail
|
||||
id={Number(closingId)}
|
||||
initialValue={closing.data}
|
||||
salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
|
||||
hppExpeditionData={
|
||||
isResponseSuccess(hppEkspedisiData)
|
||||
? hppEkspedisiData.data
|
||||
: undefined
|
||||
}
|
||||
projectData={
|
||||
isResponseSuccess(projectData) ? projectData.data : undefined
|
||||
}
|
||||
kandangData={
|
||||
isResponseSuccess(kandangData) ? kandangData.data : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingDetailPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import ClosingsTable from '@/components/pages/closing/ClosingsTable';
|
||||
|
||||
const Closing = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<ClosingsTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Closing;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { DailyChecklistContent } from '@/figma-make/components/pages/daily-checklist/DailyChecklistContent';
|
||||
|
||||
const DailyChecklistPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<DailyChecklistContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyChecklistPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Dashboard as DashboardDailyChecklist } from '@/figma-make/components/pages/dashboard/Dashboard';
|
||||
|
||||
const DailyChecklistDashboardPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<DashboardDailyChecklist />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyChecklistDashboardPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { DetailDailyChecklistContent } from '@/figma-make/components/pages/list-daily-checklist/detail/DetailDailyChecklistContent';
|
||||
|
||||
const ListDailyChecklistDetailPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<DetailDailyChecklistContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListDailyChecklistDetailPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ListDailyChecklistContent } from '@/figma-make/components/pages/list-daily-checklist/ListDailyChecklistContent';
|
||||
|
||||
const ListDailyChecklistPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<ListDailyChecklistContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListDailyChecklistPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MasterAktivitasContent } from '@/figma-make/components/pages/master-data/activity/MasterAktivitasContent';
|
||||
|
||||
const MasterAktivitasPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<MasterAktivitasContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasterAktivitasPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MasterConfigurationContent } from '@/figma-make/components/pages/master-data/configuration/MasterConfigurationContent';
|
||||
|
||||
const MasterConfigurationPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<MasterConfigurationContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasterConfigurationPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MasterEmployeeContent } from '@/figma-make/components/pages/master-data/employee/MasterEmployeeContent';
|
||||
|
||||
const MasterEmployeePage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<MasterEmployeeContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasterEmployeePage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { DailyChecklistReportsContent } from '@/figma-make/components/pages/reports/DailyChecklistReportsContent';
|
||||
|
||||
const DailyChecklistReportsPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<DailyChecklistReportsContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyChecklistReportsPage;
|
||||
@@ -1,9 +1,7 @@
|
||||
import DashboardProduction from '@/components/pages/dashboard/DashboardProduction';
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
|
||||
</section>
|
||||
);
|
||||
return <DashboardProduction />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -37,7 +37,7 @@ const ExpenseRealization = () => {
|
||||
const isExpenseCanBeRealized =
|
||||
isResponseSuccess(expense) &&
|
||||
expense.data.latest_approval.action !== 'REJECTED' &&
|
||||
expense.data.latest_approval.step_number === 3;
|
||||
expense.data.latest_approval.step_number === 4;
|
||||
|
||||
if (isResponseSuccess(expense) && !isExpenseCanBeRealized) {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const FinanceAdjust = () => {
|
||||
return <div>Finance Adjust</div>;
|
||||
};
|
||||
|
||||
export default FinanceAdjust;
|
||||
@@ -0,0 +1,7 @@
|
||||
import FormFinanceAddInitialBalance from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance';
|
||||
|
||||
const FinanceAddInitialBalancePage = () => {
|
||||
return <FormFinanceAddInitialBalance type='add' />;
|
||||
};
|
||||
|
||||
export default FinanceAddInitialBalancePage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import FormFinanceInjection from '@/components/pages/finance/add/injection/FormFinanceInjection';
|
||||
|
||||
const FinanceAddInjectionPage = () => {
|
||||
return <FormFinanceInjection type='add' />;
|
||||
};
|
||||
|
||||
export default FinanceAddInjectionPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import FormFinanceAdd from '@/components/pages/finance/add/FormFinanceAdd';
|
||||
|
||||
const FinanceAddPage = () => {
|
||||
return <FormFinanceAdd />;
|
||||
};
|
||||
|
||||
export default FinanceAddPage;
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import FormFinanceAddInitialBalance from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance';
|
||||
|
||||
const EditFinanceInitialBalancePage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const financeId = searchParams.get('financeId');
|
||||
|
||||
const { data: finance, isLoading: isLoadingFinance } = useSWR(
|
||||
financeId,
|
||||
(id: number) => FinanceApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!financeId) {
|
||||
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 (!isLoadingFinance && (!finance || isResponseError(finance))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingFinance && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
|
||||
{!isLoadingFinance && (
|
||||
<FormFinanceAddInitialBalance
|
||||
type='edit'
|
||||
initialValues={isResponseSuccess(finance) ? finance.data : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditFinanceInitialBalancePage;
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import FormFinanceInjection from '@/components/pages/finance/add/injection/FormFinanceInjection';
|
||||
|
||||
const EditFinanceInjectionPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const financeId = searchParams.get('financeId');
|
||||
|
||||
const { data: finance, isLoading: isLoadingFinance } = useSWR(
|
||||
financeId,
|
||||
(id: number) => FinanceApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!financeId) {
|
||||
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 (!isLoadingFinance && (!finance || isResponseError(finance))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingFinance && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
|
||||
{!isLoadingFinance && (
|
||||
<FormFinanceInjection
|
||||
type='edit'
|
||||
initialValues={isResponseSuccess(finance) ? finance.data : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditFinanceInjectionPage;
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import FormFinanceAdd from '@/components/pages/finance/add/FormFinanceAdd';
|
||||
import FormFinanceAddInitialBalance from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance';
|
||||
|
||||
const EditFinanceTransactionPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const financeId = searchParams.get('financeId');
|
||||
|
||||
const { data: finance, isLoading: isLoadingFinance } = useSWR(
|
||||
financeId,
|
||||
(id: number) => FinanceApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!financeId) {
|
||||
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 (!isLoadingFinance && (!finance || isResponseError(finance))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingFinance && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
|
||||
{!isLoadingFinance && (
|
||||
<FormFinanceAdd
|
||||
type='edit'
|
||||
initialValues={isResponseSuccess(finance) ? finance.data : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditFinanceTransactionPage;
|
||||
@@ -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,41 @@
|
||||
'use client';
|
||||
|
||||
import FinanceDetail from '@/components/pages/finance/FinanceDetail';
|
||||
import useSWR from 'swr';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const FinanceDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const financeId = useSearchParams().get('financeId');
|
||||
|
||||
const { data: finance } = useSWR(financeId, () =>
|
||||
FinanceApi.getSingle(Number(financeId))
|
||||
);
|
||||
|
||||
if (!financeId) {
|
||||
router.back();
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log(finance);
|
||||
|
||||
// if (!finance || isResponseError(finance)) {
|
||||
// router.replace('/404');
|
||||
// return;
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
{isResponseSuccess(finance) && <FinanceDetail finance={finance.data} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceDetailPage;
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import FinanceTable from '@/components/pages/finance/FinanceTable';
|
||||
|
||||
const Finance = () => {
|
||||
return (
|
||||
<section className='size-full p-6'>
|
||||
<div className='flex flex-row gap-4'></div>
|
||||
<FinanceTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Finance;
|
||||
+40
-20
@@ -1,32 +1,46 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin "daisyui";
|
||||
@import '../styles/daisyui.css';
|
||||
@import '../figma-make/styles/theme.css';
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'lti';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
--color-base-100: oklch(98% 0.001 106.423);
|
||||
--color-base-200: oklch(97% 0.001 106.424);
|
||||
--color-base-300: oklch(92% 0.003 48.717);
|
||||
--color-base-content: oklch(22.389% 0.031 278.072);
|
||||
--color-primary: oklch(60% 0.126 221.723);
|
||||
--color-primary-content: oklch(100% 0 0);
|
||||
--color-secondary: oklch(52% 0.105 223.128);
|
||||
--color-secondary-content: oklch(100% 0 0);
|
||||
--color-accent: oklch(45% 0.085 224.283);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(39% 0.07 227.392);
|
||||
--color-neutral-content: oklch(100% 0 0);
|
||||
--color-info: oklch(58% 0.158 241.966);
|
||||
--color-info-content: oklch(100% 0 0);
|
||||
--color-success: oklch(62% 0.194 149.214);
|
||||
--color-success-content: oklch(100% 0 0);
|
||||
--color-warning: oklch(85% 0.199 91.936);
|
||||
--color-warning-content: oklch(0% 0 0);
|
||||
--color-error: oklch(57% 0.245 27.325);
|
||||
--color-error-content: oklch(100% 0 0);
|
||||
|
||||
/* Primary Colors */
|
||||
--color-primary: oklch(39.4% 0.177 301.9);
|
||||
--color-primary-content: oklch(87.5% 0.038 274.5);
|
||||
|
||||
/* Secondary Colors */
|
||||
--color-secondary: oklch(60.1% 0.258 335.7);
|
||||
--color-secondary-content: oklch(99.4% 0.007 337.8);
|
||||
|
||||
/* Accent Colors */
|
||||
--color-accent: oklch(76.2% 0.155 170.8);
|
||||
--color-accent-content: oklch(7.2% 0.007 167.6);
|
||||
|
||||
/* Neutral Colors */
|
||||
--color-neutral: oklch(22.4% 0.032 258.8);
|
||||
--color-neutral-content: oklch(87.7% 0.016 257);
|
||||
|
||||
/* Base Colors */
|
||||
--color-base-100: oklch(100% 0 0); /* #ffffff */
|
||||
--color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */
|
||||
--color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */
|
||||
--color-base-content: oklch(18.6% 0.024 257.7); /* #1f2937 */
|
||||
|
||||
/* Status/Utility Colors */
|
||||
--color-info: oklch(67.4% 0.176 238.9);
|
||||
--color-info-content: oklch(0% 0 0); /* #000000 */
|
||||
--color-success: oklch(62.3% 0.147 149);
|
||||
--color-success-content: oklch(100% 0 0); /* #ffffff */
|
||||
--color-warning: oklch(82.2% 0.165 91.9);
|
||||
--color-warning-content: oklch(0% 0 0); /* #000000 */
|
||||
--color-error: oklch(61.8% 0.203 27.8);
|
||||
--color-error-content: oklch(100% 0 0); /* #fffffff */
|
||||
|
||||
--radius-selector: 0rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.25rem;
|
||||
@@ -43,6 +57,12 @@
|
||||
|
||||
@theme {
|
||||
--font-inter: var(--font-inter);
|
||||
|
||||
--container-sm: 40rem;
|
||||
--container-md: 48rem;
|
||||
--container-lg: 64rem;
|
||||
--container-xl: 80rem;
|
||||
--container-2xl: 96rem;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -12,8 +12,6 @@ const DetailInventoryAdjustment = () => {
|
||||
|
||||
// Ambil data dari router state
|
||||
useEffect(() => {
|
||||
console.log('Router State');
|
||||
console.log(window.history.state);
|
||||
const state = window.history.state?.usr as
|
||||
| { inventoryAdjustment?: InventoryAdjustment }
|
||||
| undefined;
|
||||
@@ -26,9 +24,6 @@ const DetailInventoryAdjustment = () => {
|
||||
|
||||
const finalData = inventoryAdjustment;
|
||||
|
||||
console.log('Final Data');
|
||||
console.log(finalData);
|
||||
|
||||
if (!finalData) {
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import InventoryProductDetail from '@/components/pages/inventory/product/detail/InventoryProductDetail';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { InventoryProductApi } from '@/services/api/inventory';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const InventoryProductDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const inventoryProductId = searchParams.get('inventoryProductId');
|
||||
|
||||
const { data: inventoryProduct, isLoading: isLoadingInventoryProduct } =
|
||||
useSWR(inventoryProductId, (id: number) =>
|
||||
InventoryProductApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!inventoryProductId) {
|
||||
router.back();
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isLoadingInventoryProduct &&
|
||||
(!inventoryProduct || isResponseError(inventoryProduct))
|
||||
) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='size-full'>
|
||||
{isLoadingInventoryProduct && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingInventoryProduct && isResponseSuccess(inventoryProduct) && (
|
||||
<InventoryProductDetail inventoryProduct={inventoryProduct.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryProductDetailPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import InventoryProductTable from '@/components/pages/inventory/product/InventoryProductTable';
|
||||
|
||||
const InventoryProductPage = () => {
|
||||
return (
|
||||
<div className='size-full'>
|
||||
<InventoryProductTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryProductPage;
|
||||
@@ -3,6 +3,7 @@ import { Inter } from 'next/font/google';
|
||||
import '@/app/globals.css';
|
||||
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { Toaster as SonnerToaster } from '@/figma-make/components/base/sonner';
|
||||
import MainDrawer from '@/components/MainDrawer';
|
||||
import RequireAuth from '@/components/helper/RequireAuth';
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function RootLayout({
|
||||
</RequireAuth>
|
||||
|
||||
<Toaster />
|
||||
<SonnerToaster position='top-right' />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -7,4 +7,5 @@ const Marketing = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Marketing;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import ProductionStandardForm from '@/components/pages/master-data/production-standard/form/ProductionStandardForm';
|
||||
|
||||
const AddProductionStandardPage = () => {
|
||||
return (
|
||||
<>
|
||||
<ProductionStandardForm formType='add' />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddProductionStandardPage;
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import ProductionStandardForm from '@/components/pages/master-data/production-standard/form/ProductionStandardForm';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { ProductionStandardApi } from '@/services/api/master-data';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const EditProductionStandardPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Get Query Params
|
||||
const productionStandardId = searchParams.get('productionStandardId');
|
||||
|
||||
// Fetch Data
|
||||
const { data: productionStandard, isLoading: isLoadingProductionStandard } =
|
||||
useSWR(productionStandardId, (id: number) =>
|
||||
ProductionStandardApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!productionStandardId) {
|
||||
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 (
|
||||
!isLoadingProductionStandard &&
|
||||
(!productionStandard || isResponseError(productionStandard))
|
||||
) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingProductionStandard && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingProductionStandard &&
|
||||
isResponseSuccess(productionStandard) && (
|
||||
<ProductionStandardForm
|
||||
formType='edit'
|
||||
initialValue={productionStandard.data}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProductionStandardPage;
|
||||
@@ -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,56 @@
|
||||
'use client';
|
||||
|
||||
import ProductionStandardForm from '@/components/pages/master-data/production-standard/form/ProductionStandardForm';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { ProductionStandardApi } from '@/services/api/master-data';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const DetailProductionStandardPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Get Query Params
|
||||
const productionStandardId = searchParams.get('productionStandardId');
|
||||
|
||||
// Fetch Data
|
||||
const { data: productionStandard, isLoading: isLoadingProductionStandard } =
|
||||
useSWR(productionStandardId, (id: number) =>
|
||||
ProductionStandardApi.getSingle(id)
|
||||
);
|
||||
|
||||
if (!productionStandardId) {
|
||||
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 (
|
||||
!isLoadingProductionStandard &&
|
||||
(!productionStandard || isResponseError(productionStandard))
|
||||
) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingProductionStandard && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingProductionStandard &&
|
||||
isResponseSuccess(productionStandard) && (
|
||||
<ProductionStandardForm
|
||||
formType='detail'
|
||||
initialValue={productionStandard.data}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailProductionStandardPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable';
|
||||
|
||||
const ProductionStandardPage = () => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<ProductionStandardTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionStandardPage;
|
||||
+25
-7
@@ -1,11 +1,29 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { redirectToSSO } from '@/lib/auth-helper';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard');
|
||||
const { user, isLoadingUser } = useAuth();
|
||||
|
||||
return (
|
||||
<main className='w-full h-full min-h-screen flex flex-row justify-center items-center'>
|
||||
<h1>LTI ERP</h1>
|
||||
</main>
|
||||
);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/') {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoadingUser) {
|
||||
return (
|
||||
<main className='w-full h-full min-h-screen flex flex-row justify-center items-center'>
|
||||
<span className='loading loading-spinner loading-lg'></span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <>Loading...</>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
|
||||
import React, { useImperativeHandle } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const AddProjectFlock = () => {
|
||||
// useImperativeHandle(ref, () => ({
|
||||
// validate() {
|
||||
// toast.success('Validating');
|
||||
// return false;
|
||||
// },
|
||||
// }));
|
||||
return (
|
||||
<section className='w-full p-4 flex flex-row justify-center'>
|
||||
<section className='w-full flex flex-row justify-center'>
|
||||
<ProjectFlockForm formType='add' />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function AddChickinKandang() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full p-4'>
|
||||
<section className='size-full'>
|
||||
{isLoading && <span className='loading loading-spinner loading-xl' />}
|
||||
{!isLoading &&
|
||||
isResponseSuccess(projectFlockKandang) &&
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||
import ProjectFlockChickinDetail from '@/components/pages/production/project-flock/chickin/ProjectFlockChickinDetail';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
const AddChickin = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const projectFlockId = searchParams.get('projectFlockId');
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full p-4'>
|
||||
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddChickin;
|
||||
@@ -1,10 +0,0 @@
|
||||
import ChickinTable from '@/components/pages/production/chickin/ChickinTable';
|
||||
|
||||
const Chickin = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<ChickinTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
export default Chickin;
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
import ProjectFlockClosingForm from '@/components/pages/production/project-flock/closing/ProjectFlockClosingForm';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { ProjectFlockKandangApi } from '@/services/api/production';
|
||||
import { ProjectFlockApi } from '@/services/api/production/project-flock';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const ProjectFlockClosingPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const projectFlockId = searchParams.get('projectFlockId');
|
||||
const projectFlockKandangId = searchParams.get('projectFlockKandangId');
|
||||
|
||||
const { data: projectFlockKandang, isLoading: isLoadingProjectFlockKandang } =
|
||||
useSWR(`get-flock-kandang-id/${projectFlockKandangId}`, () =>
|
||||
ProjectFlockKandangApi.getSingle(parseInt(projectFlockKandangId ?? ''))
|
||||
);
|
||||
|
||||
const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR(
|
||||
`get-flock-id/${projectFlockId}`,
|
||||
() => ProjectFlockApi.getSingle(parseInt(projectFlockId ?? ''))
|
||||
);
|
||||
|
||||
if (!projectFlockId || !projectFlockKandangId) {
|
||||
router.back();
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isLoadingProjectFlock &&
|
||||
(!projectFlock || isResponseError(projectFlock)) &&
|
||||
!isLoadingProjectFlockKandang &&
|
||||
(!projectFlockKandang || isResponseError(projectFlockKandang))
|
||||
) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col justify-center'>
|
||||
{isLoadingProjectFlock ||
|
||||
(isLoadingProjectFlockKandang && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
))}
|
||||
{isResponseSuccess(projectFlock) &&
|
||||
isResponseSuccess(projectFlockKandang) && (
|
||||
<ProjectFlockClosingForm
|
||||
projectFlock={projectFlock.data}
|
||||
projectFlockKandang={projectFlockKandang.data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectFlockClosingPage;
|
||||
@@ -37,7 +37,7 @@ const ProjectFlockEdit = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-col justify-center'>
|
||||
<div className='w-full flex flex-col justify-center'>
|
||||
{isLoadingProjectFlock && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import ProjectFlockDetail from '@/components/pages/production/project-flock/detail/ProjectFlockDetail';
|
||||
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { ProjectFlockApi } from '@/services/api/production/project-flock';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const ProjectFlockDetail = () => {
|
||||
const ProjectFlockDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -37,19 +38,17 @@ const ProjectFlockDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-col justify-center'>
|
||||
<div className='w-full h-full flex flex-col justify-center'>
|
||||
{isLoadingProjectFlock && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{isResponseSuccess(projectFlock) && (
|
||||
<ProjectFlockForm
|
||||
formType='detail'
|
||||
initialValues={projectFlock.data}
|
||||
refreshProjectFlocks={refreshProjectFlock}
|
||||
/>
|
||||
<ProjectFlockDetail projectFlock={projectFlock.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectFlockDetail;
|
||||
export default ProjectFlockDetailPage;
|
||||
ProjectFlockDetail;
|
||||
ProjectFlockDetail;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Drawer from '@/components/Drawer';
|
||||
import React, { ReactNode } from 'react';
|
||||
import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
export default function ProjectFlockLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const toggleValidate = useUiStore((s) => s.toggleValidate);
|
||||
|
||||
const isAdd = pathname.includes('/add');
|
||||
const isEdit = pathname.includes('/detail/edit');
|
||||
const isDetail = pathname.includes('/detail');
|
||||
const isChickin = pathname.includes('/chickin/add/kandang');
|
||||
const isClosing = pathname.includes('/closing');
|
||||
|
||||
const isOpen = isAdd || isEdit || isDetail || isChickin || isClosing;
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
const unsub = useUiStore.getState().subscribeIsValid((isValid) => {
|
||||
if (isValid) {
|
||||
unsub(); // berhenti listen
|
||||
router.push('/production/project-flock');
|
||||
}
|
||||
});
|
||||
|
||||
toggleValidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* List page always rendered */}
|
||||
<div className='min-h-sceen w-full relative'>
|
||||
<ProjectFlockTable
|
||||
refresh={() => !isOpen && router.push('/production/project-flock')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Render Drawer only on /add */}
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
setOpen={(v) => {
|
||||
if (!v) router.push('/production/project-flock');
|
||||
}}
|
||||
closeOnBackdropClick={isDetail ? true : false}
|
||||
onBackdropClick={handleBackdropClick}
|
||||
variant='right'
|
||||
zIndex='99999'
|
||||
sidebarContent={isOpen && <div className=''>{children}</div>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import ProjectFlockTable from '@/components/pages/production/project-flock/Proje
|
||||
|
||||
const ProjectFlock = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<section className='size-full p-4'>
|
||||
<ProjectFlockTable />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ const RecordingEdit = () => {
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId,
|
||||
(id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi
|
||||
(id: string) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!recordingId) {
|
||||
|
||||
@@ -14,7 +14,7 @@ const RecordingDetail = () => {
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId,
|
||||
(id: number) => RecordingApi.getSingle(id)
|
||||
(id: string) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!recordingId) {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
|
||||
import { RecordingApi } from '@/services/api/production';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const AddGrading = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const recordingId = searchParams.get('recording_id');
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId && recordingId !== 'new' ? [recordingId] : null,
|
||||
([id]) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (
|
||||
recordingId &&
|
||||
recordingId !== 'new' &&
|
||||
!isLoadingRecording &&
|
||||
(!recording || !isResponseSuccess(recording))
|
||||
) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{recordingId && recordingId !== 'new' && isLoadingRecording && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{(!recordingId ||
|
||||
recordingId === 'new' ||
|
||||
(!isLoadingRecording && recording && isResponseSuccess(recording))) && (
|
||||
<GradingForm
|
||||
type='add'
|
||||
initialValues={
|
||||
isResponseSuccess(recording) ? recording.data?.eggs?.[0] : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddGrading;
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
|
||||
import { RecordingApi } from '@/services/api/production';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const EditGrading = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const recordingId = searchParams.get('recordingId');
|
||||
const gradingId = searchParams.get('gradingId');
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId ? [recordingId] : null,
|
||||
([id]) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!recordingId) {
|
||||
router.back();
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingRecording && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingRecording && recording && isResponseSuccess(recording) && (
|
||||
<GradingForm
|
||||
type='edit'
|
||||
initialValues={recording.data.eggs?.find(
|
||||
(egg) => egg.id === parseInt(gradingId || '0')
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditGrading;
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
|
||||
import { RecordingApi } from '@/services/api/production';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const DetailGrading = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const gradingId = searchParams.get('gradingId');
|
||||
|
||||
const { data: grading, isLoading: isLoadingGrading } = useSWR(
|
||||
gradingId ? [gradingId] : null,
|
||||
([id]) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!gradingId) {
|
||||
router.back();
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
{isLoadingGrading && (
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
)}
|
||||
{!isLoadingGrading && grading && isResponseSuccess(grading) && (
|
||||
<GradingForm
|
||||
type='detail'
|
||||
initialValues={grading.data.eggs?.find(
|
||||
(egg) => egg.id === parseInt(gradingId)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailGrading;
|
||||
@@ -0,0 +1,7 @@
|
||||
import UniformityForm from '@/components/pages/production/uniformity/form/UniformityForm';
|
||||
|
||||
const AddUniformity = () => {
|
||||
return <UniformityForm formType='add' />;
|
||||
};
|
||||
|
||||
export default AddUniformity;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import UniformityDetail from '@/components/pages/production/uniformity/detail/UniformityDetail';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { UniformityApi } from '@/services/api/uniformity';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const UniformityDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const uniformityId = searchParams.get('uniformityId');
|
||||
|
||||
const { data: uniformity, isLoading: isLoadingUniformity } = useSWR(
|
||||
uniformityId,
|
||||
(id: string) => UniformityApi.getUniformityDetail(parseInt(id))
|
||||
);
|
||||
|
||||
if (!uniformityId) {
|
||||
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 (!isLoadingUniformity && (!uniformity || isResponseError(uniformity))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col justify-center'>
|
||||
{isLoadingUniformity && (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4 min-h-screen'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
)}
|
||||
{isResponseSuccess(uniformity) && (
|
||||
<UniformityDetail initialValues={uniformity.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UniformityDetailPage;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import UniformityPageWrapper from '@/components/pages/production/uniformity/UniformityPageWrapper';
|
||||
|
||||
export default function UniformityLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <UniformityPageWrapper>{children}</UniformityPageWrapper>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import UniformityTable from '@/components/pages/production/uniformity/UniformityTable';
|
||||
|
||||
const Uniformity = () => {
|
||||
return <UniformityTable />;
|
||||
};
|
||||
|
||||
export default Uniformity;
|
||||
@@ -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,5 @@
|
||||
const ReportExpenseDetail = () => {
|
||||
return <div>ReportExpenseDetail</div>;
|
||||
};
|
||||
|
||||
export default ReportExpenseDetail;
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable';
|
||||
|
||||
const ReportExpense = () => {
|
||||
return (
|
||||
<div className='w-full p-4'>
|
||||
<ReportExpenseTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportExpense;
|
||||
@@ -0,0 +1,7 @@
|
||||
import FinanceTabs from '@/components/pages/report/finance/FinanceTabs';
|
||||
|
||||
const Finance = () => {
|
||||
return <FinanceTabs />;
|
||||
};
|
||||
|
||||
export default Finance;
|
||||
@@ -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,7 @@
|
||||
import LogisticStockTabs from '@/components/pages/report/logistic-stock/LogisticStockTabs';
|
||||
|
||||
const LogisticStock = () => {
|
||||
return <LogisticStockTabs />;
|
||||
};
|
||||
|
||||
export default LogisticStock;
|
||||
@@ -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,11 @@
|
||||
import MarketingReportContent from '@/components/pages/report/MarketingReportContent';
|
||||
|
||||
const MarketingReportPage = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<MarketingReportContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketingReportPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent';
|
||||
|
||||
const ProductionResultReportPage = () => {
|
||||
return (
|
||||
<section className='w-full max-w-7xl pb-16'>
|
||||
<ProductionResultContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionResultReportPage;
|
||||
+34
-14
@@ -3,29 +3,25 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
import type { Color, Variant, Size } from '@/types/theme';
|
||||
|
||||
export interface BadgeProps
|
||||
extends Omit<HTMLAttributes<HTMLSpanElement>, 'className'> {
|
||||
children?: ReactNode;
|
||||
className?: {
|
||||
badge?: string;
|
||||
status?: string;
|
||||
};
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'soft' | 'dash';
|
||||
color?:
|
||||
| 'neutral'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'accent'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
statusIndicator?: boolean;
|
||||
variant?: Variant;
|
||||
color?: Color;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const Badge = ({
|
||||
children,
|
||||
className,
|
||||
statusIndicator = false,
|
||||
variant = 'default',
|
||||
color,
|
||||
size = 'md',
|
||||
@@ -34,7 +30,7 @@ const Badge = ({
|
||||
const getBadgeClasses = () => {
|
||||
const baseClasses = 'badge';
|
||||
|
||||
const variantClasses = {
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: '',
|
||||
outline: 'badge-outline',
|
||||
ghost: 'badge-ghost',
|
||||
@@ -42,7 +38,7 @@ const Badge = ({
|
||||
dash: 'badge-dash',
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
const colorClasses: Record<Color, string> = {
|
||||
neutral: 'badge-neutral',
|
||||
primary: 'badge-primary',
|
||||
secondary: 'badge-secondary',
|
||||
@@ -51,9 +47,10 @@ const Badge = ({
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
error: 'badge-error',
|
||||
none: '',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
xs: 'badge-xs',
|
||||
sm: 'badge-sm',
|
||||
md: 'badge-md',
|
||||
@@ -70,8 +67,31 @@ const Badge = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusClasses = () => {
|
||||
if (!statusIndicator) return '';
|
||||
|
||||
const statusIndicatorClasses: Record<Color, string> = {
|
||||
neutral: 'bg-neutral',
|
||||
primary: 'bg-primary',
|
||||
secondary: 'bg-secondary',
|
||||
accent: 'bg-accent',
|
||||
info: 'bg-info',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
none: '',
|
||||
};
|
||||
|
||||
return cn(
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
color && statusIndicatorClasses[color],
|
||||
className?.status
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={getBadgeClasses()} {...props}>
|
||||
{statusIndicator && <span className={getStatusClasses()} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HTMLAttributes, ReactNode, useState } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
import Image from 'next/image';
|
||||
import Collapse from './Collapse';
|
||||
import Collapse from '@/components/Collapse';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
export interface CardProps
|
||||
|
||||
+140
-9
@@ -10,28 +10,115 @@ interface DrawerProps {
|
||||
open: boolean;
|
||||
setOpen: (newOpenState: boolean) => void;
|
||||
openOnLarge?: boolean;
|
||||
variant?: 'sidebar' | 'left' | 'right';
|
||||
zIndex?: string;
|
||||
className?: DrawerClassName;
|
||||
onBackdropClick?: () => void;
|
||||
closeOnBackdropClick?: boolean;
|
||||
expandedContent?: ReactNode;
|
||||
expandedWidth?: string;
|
||||
}
|
||||
|
||||
type DrawerClassName = {
|
||||
drawer?: string;
|
||||
drawerContent?: string;
|
||||
drawerSide?: string;
|
||||
drawerOverlay?: string;
|
||||
drawerSidebarContent?: string;
|
||||
};
|
||||
|
||||
const Drawer = ({
|
||||
children,
|
||||
sidebarContent,
|
||||
open,
|
||||
setOpen,
|
||||
openOnLarge,
|
||||
variant = 'sidebar',
|
||||
zIndex = '20',
|
||||
className,
|
||||
onBackdropClick,
|
||||
closeOnBackdropClick = true,
|
||||
expandedContent,
|
||||
expandedWidth = 'w-[400px]',
|
||||
}: DrawerProps) => {
|
||||
const getDrawerClassNames = (): DrawerClassName => {
|
||||
const baseClassNames = {
|
||||
drawer: 'drawer',
|
||||
drawerContent: 'drawer-content',
|
||||
drawerSide: 'drawer-side',
|
||||
drawerOverlay: 'drawer-overlay',
|
||||
drawerSidebarContent: 'min-h-full bg-base-100',
|
||||
};
|
||||
|
||||
const getSidebarWidth = () => {
|
||||
if (variant === 'sidebar') {
|
||||
return expandedContent
|
||||
? 'w-full lg:min-w-[600px] lg:max-w-[600px]'
|
||||
: 'w-full max-w-[300px] lg:w-[300px]';
|
||||
}
|
||||
return 'w-full sm:min-w-120 sm:w-fit';
|
||||
};
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return {
|
||||
...baseClassNames,
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
getSidebarWidth()
|
||||
),
|
||||
};
|
||||
} else if (variant === 'right') {
|
||||
return {
|
||||
...baseClassNames,
|
||||
drawer: cn(baseClassNames.drawer, 'drawer-end'),
|
||||
drawerSide: cn(
|
||||
baseClassNames.drawerSide,
|
||||
'border-l border-solid border-gray-200 sm:drawer-side w-screen top-0 right-0 fixed z-21'
|
||||
),
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
getSidebarWidth()
|
||||
),
|
||||
};
|
||||
} else if (variant === 'left') {
|
||||
return {
|
||||
...baseClassNames,
|
||||
drawerSide: cn(
|
||||
baseClassNames.drawerSide,
|
||||
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
|
||||
),
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
getSidebarWidth()
|
||||
),
|
||||
};
|
||||
}
|
||||
return baseClassNames; // Fallback for default or unknown variant
|
||||
};
|
||||
|
||||
const varianClassName = getDrawerClassNames();
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setOpen(false);
|
||||
if (closeOnBackdropClick) {
|
||||
setOpen(false);
|
||||
}
|
||||
onBackdropClick && onBackdropClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('drawer', {
|
||||
'lg:drawer-open': openOnLarge,
|
||||
})}
|
||||
className={cn(
|
||||
'drawer',
|
||||
{
|
||||
'lg:drawer-open': openOnLarge,
|
||||
},
|
||||
varianClassName?.drawer,
|
||||
className?.drawer
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='checkbox'
|
||||
@@ -40,17 +127,61 @@ const Drawer = ({
|
||||
className='drawer-toggle'
|
||||
/>
|
||||
|
||||
<div className='drawer-content'>{children}</div>
|
||||
{/* Drawer Content */}
|
||||
<div
|
||||
className={cn(varianClassName?.drawerContent, className?.drawerContent)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className='drawer-side border-r border-solid border-gray-200 z-20'>
|
||||
{/* Drawer Side */}
|
||||
<div
|
||||
className={cn(
|
||||
varianClassName?.drawerSide,
|
||||
className?.drawerSide,
|
||||
zIndex
|
||||
)}
|
||||
>
|
||||
<label
|
||||
aria-label='close sidebar'
|
||||
className='drawer-overlay'
|
||||
className={cn(
|
||||
varianClassName?.drawerOverlay,
|
||||
className?.drawerOverlay
|
||||
)}
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
|
||||
<div className='min-h-full w-full max-w-[300px] lg:w-[300px] bg-base-100'>
|
||||
{sidebarContent}
|
||||
{/* Sidebar Content - Full height container */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-screen bg-base-100 overflow-hidden',
|
||||
variant === 'right' && 'flex-row'
|
||||
)}
|
||||
>
|
||||
{/* Primary Sidebar Content */}
|
||||
<div
|
||||
className={cn(
|
||||
varianClassName?.drawerSidebarContent,
|
||||
className?.drawerContent,
|
||||
'overflow-y-auto'
|
||||
)}
|
||||
>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Expanded Drawer (Right side, side-by-side) */}
|
||||
{expandedContent && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-l border-gray-200 bg-white flex flex-col h-full',
|
||||
expandedWidth
|
||||
)}
|
||||
>
|
||||
<div className='overflow-y-auto flex-1 h-full'>
|
||||
{expandedContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { ReactNode, useState, useRef } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
trigger?: string;
|
||||
content?: string;
|
||||
};
|
||||
align?: 'start' | 'center' | 'end';
|
||||
direction?: 'top' | 'bottom' | 'left' | 'right';
|
||||
hover?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
close?: boolean;
|
||||
controlled?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown = ({
|
||||
trigger,
|
||||
children,
|
||||
className,
|
||||
align,
|
||||
direction,
|
||||
hover,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
close,
|
||||
controlled = false,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!controlled) {
|
||||
const newState = !isOpen;
|
||||
setIsOpen(newState);
|
||||
}
|
||||
};
|
||||
|
||||
const getWrapperClasses = () => {
|
||||
const openState = controlled ? open : isOpen;
|
||||
|
||||
return cn(
|
||||
'dropdown',
|
||||
{
|
||||
'dropdown-start': align === 'start',
|
||||
'dropdown-center': align === 'center',
|
||||
'dropdown-end': align === 'end',
|
||||
'dropdown-top': direction === 'top',
|
||||
'dropdown-bottom': direction === 'bottom',
|
||||
'dropdown-left': direction === 'left',
|
||||
'dropdown-right': direction === 'right',
|
||||
'dropdown-hover': hover,
|
||||
'dropdown-open': openState && !close,
|
||||
'dropdown-close': close,
|
||||
},
|
||||
className?.wrapper
|
||||
);
|
||||
};
|
||||
|
||||
const getTriggerClasses = () => {
|
||||
return cn(className?.trigger);
|
||||
};
|
||||
|
||||
const getContentClasses = () => {
|
||||
return cn(
|
||||
'dropdown-content z-[9999] shadow-sm bg-base-100 rounded-box',
|
||||
className?.content
|
||||
);
|
||||
};
|
||||
|
||||
if (controlled) {
|
||||
return (
|
||||
<div className={getWrapperClasses()}>
|
||||
{trigger}
|
||||
{open && !close && (
|
||||
<div tabIndex={-1} className={getContentClasses()}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={getWrapperClasses()}>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
className={getTriggerClasses()}
|
||||
onClick={toggleDropdown}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
{!close && (
|
||||
<div tabIndex={-1} className={getContentClasses()}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
|
||||
type FloatingActionsButtonProps = {
|
||||
actions: {
|
||||
action: 'DETAIL' | 'EDIT' | 'DELETE';
|
||||
icon: string;
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
permissions?: string | string[];
|
||||
}[];
|
||||
approvals: {
|
||||
action: 'APPROVED' | 'REJECTED';
|
||||
icon: string;
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
permissions?: string | string[];
|
||||
}[];
|
||||
selectedRowIds: number[];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const FloatingActionsButton = ({
|
||||
actions,
|
||||
approvals,
|
||||
selectedRowIds,
|
||||
onClose,
|
||||
}: FloatingActionsButtonProps) => {
|
||||
const { permissionCheck } = useAuth();
|
||||
// Jika tidak ada baris yang dipilih, jangan tampilkan FAB
|
||||
const positionStyles =
|
||||
selectedRowIds.length > 0
|
||||
? 'bottom-[5%] opacity-100'
|
||||
: 'bottom-[-5%] opacity-0';
|
||||
|
||||
// Helper untuk menentukan gaya warna tombol approval
|
||||
const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => {
|
||||
if (action === 'APPROVED') return 'success';
|
||||
if (action === 'REJECTED') return 'error';
|
||||
return 'primary';
|
||||
};
|
||||
|
||||
const getActionColor = (action: 'DETAIL' | 'EDIT' | 'DELETE') => {
|
||||
if (action === 'DETAIL') return 'white';
|
||||
if (action === 'EDIT') return 'warning';
|
||||
if (action === 'DELETE') return 'error';
|
||||
return 'primary';
|
||||
};
|
||||
|
||||
return (
|
||||
// Container utama FAB
|
||||
<div
|
||||
className={cn(
|
||||
`fixed ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`,
|
||||
'mx-auto w-full max-w-sm sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
|
||||
'bg-slate-950 backdrop-blur-md'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{/* === BARIS ATAS: Status Seleksi dan Actions (Termasuk Close) === */}
|
||||
<div className='flex justify-between items-center text-white'>
|
||||
<h4 className='text-base font-semibold'>
|
||||
{selectedRowIds.length} Selected
|
||||
</h4>
|
||||
|
||||
<div className='flex flex-row gap-1 items-stretch'>
|
||||
<div className='flex gap-4 items-center'>
|
||||
{/* Render Aksi dari props.actions */}
|
||||
{actions
|
||||
.filter((action) => {
|
||||
if (action.hidden) return false;
|
||||
if (action.permissions) {
|
||||
if (typeof action.permissions === 'string') {
|
||||
return permissionCheck(action.permissions);
|
||||
}
|
||||
return action.permissions.some((permission) =>
|
||||
permissionCheck(permission)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((action, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
className='text-white hover:text-gray-400 tooltip tooltip-bottom p-0'
|
||||
variant='link'
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<Tooltip content={action.label || action.action}>
|
||||
<Icon
|
||||
icon={action.icon}
|
||||
width={20}
|
||||
height={20}
|
||||
className={`text-${getActionColor(action.action)} font-thin`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className='border-[0.5px] border-white/30 h-full'></div>
|
||||
|
||||
{/* Tombol Close */}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className='text-white hover:text-gray-400 p-0'
|
||||
variant='link'
|
||||
>
|
||||
<Tooltip content='Close'>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === BARIS BAWAH: Approval Buttons (Approve/Reject) === */}
|
||||
<div className={`grid grid-cols-${approvals.length} gap-3`}>
|
||||
{approvals
|
||||
.filter((approval) => {
|
||||
if (approval.permissions) {
|
||||
if (typeof approval.permissions === 'string') {
|
||||
return permissionCheck(approval.permissions);
|
||||
}
|
||||
return approval.permissions.some((permission) =>
|
||||
permissionCheck(permission)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((approval, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={approval.onClick}
|
||||
className={cn(
|
||||
'btn btn-lg w-full',
|
||||
'bg-white/20 border-white/30',
|
||||
'text-white/50 font-semibold flex items-center gap-2 rounded-lg transition-all duration-200',
|
||||
approval.disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'hover:text-white/100 hover:bg-white/40 hover:border-white/50'
|
||||
)}
|
||||
disabled={approval.disabled}
|
||||
>
|
||||
<Icon
|
||||
icon={approval.icon}
|
||||
width={20}
|
||||
height={20}
|
||||
className={`text-${getApprovalColor(approval.action)}`}
|
||||
/>
|
||||
{approval.label || approval.action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingActionsButton;
|
||||
+21
-147
@@ -1,161 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Drawer from '@/components/Drawer';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import Collapse from '@/components/Collapse';
|
||||
import Button from '@/components/Button';
|
||||
import SidebarMenu from '@/components/molecules/SidebarMenu';
|
||||
import PermissionNotFound from '@/components/helper/PermissionNotFound';
|
||||
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { MAIN_DRAWER_LINKS } from '@/config/constant';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
type CollapseMenuProps = {
|
||||
title: string;
|
||||
link: string;
|
||||
icon: string;
|
||||
submenu?: CollapseMenuProps[];
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
const isPathActive = (pathname: string, link?: string) => {
|
||||
if (!link) return false;
|
||||
|
||||
const splittedPathname = pathname.split('/');
|
||||
const splittedLink = link.split('/');
|
||||
|
||||
const isActiveLinkValid = splittedLink.every((linkChunk, idx) => {
|
||||
return linkChunk === splittedPathname[idx];
|
||||
});
|
||||
|
||||
return pathname.startsWith(link) && isActiveLinkValid;
|
||||
};
|
||||
|
||||
const CollapseMenu = ({
|
||||
title,
|
||||
link,
|
||||
icon,
|
||||
submenu,
|
||||
depth = 0,
|
||||
}: CollapseMenuProps) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = isPathActive(pathname, link);
|
||||
const [open, setOpen] = useState(isActive);
|
||||
|
||||
const menuCollapseTitle = (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md text-base font-semibold transition-colors flex flex-row justify-between items-center gap-2 hover:bg-primary/10 opacity-40',
|
||||
{
|
||||
'bg-primary/10 opacity-100': open || isActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-row items-center gap-2'>
|
||||
<Icon icon={icon} width={20} height={20} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon='cuida:caret-up-outline'
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn('transition-transform', {
|
||||
'rotate-90': !open,
|
||||
'rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
open={open}
|
||||
title={menuCollapseTitle}
|
||||
onOpenChange={setOpen}
|
||||
className='w-full'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<Menu>
|
||||
<div
|
||||
className='w-full py-0.5 flex flex-col gap-0.5'
|
||||
style={{
|
||||
paddingLeft: `${0.5 * (depth + 1)}rem`,
|
||||
}}
|
||||
>
|
||||
{submenu?.map((item, idx) => {
|
||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||
|
||||
if (!hasSubmenu) {
|
||||
return (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
title={item.title}
|
||||
href={item.link}
|
||||
icon={item.icon}
|
||||
active={isPathActive(pathname, item.link)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapseMenu
|
||||
key={idx}
|
||||
title={item.title}
|
||||
link={item.link}
|
||||
icon={item.icon}
|
||||
submenu={item.submenu}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Menu>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
const MainDrawerMenu = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
{MAIN_DRAWER_LINKS.map((item, idx) => {
|
||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||
|
||||
if (!hasSubmenu) {
|
||||
return (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
title={item.title}
|
||||
href={item.link}
|
||||
icon={item.icon}
|
||||
active={pathname.startsWith(item.link)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapseMenu
|
||||
key={idx}
|
||||
title={item.title}
|
||||
link={item.link}
|
||||
icon={item.icon}
|
||||
submenu={item.submenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
import { isPathActive } from '@/lib/helper';
|
||||
import { ROUTE_PERMISSIONS } from '@/config/route-permission';
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
|
||||
const MainDrawerContent = () => {
|
||||
const pathname = usePathname();
|
||||
const { setMainDrawerOpen } = useUiStore();
|
||||
|
||||
const closeMainDrawerHandler = () => {
|
||||
@@ -191,7 +54,7 @@ const MainDrawerContent = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MainDrawerMenu />
|
||||
<SidebarMenu menu={MAIN_DRAWER_LINKS} activeLink={pathname} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -202,6 +65,13 @@ const MainDrawer = ({
|
||||
}>) => {
|
||||
const { mainDrawerOpen, setMainDrawerOpen } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
const { permissionCheck } = useAuth();
|
||||
|
||||
const formattedPathname = pathname.endsWith('/') ? pathname : `${pathname}/`;
|
||||
|
||||
const isPermitted = ROUTE_PERMISSIONS[formattedPathname]?.some((permission) =>
|
||||
permissionCheck(permission)
|
||||
);
|
||||
|
||||
const getPageTitle = useCallback(() => {
|
||||
let title = '';
|
||||
@@ -216,9 +86,9 @@ const MainDrawer = ({
|
||||
const hasSubmenu = menu?.submenu && menu?.submenu.length > 0;
|
||||
|
||||
if (!title) {
|
||||
title += menu?.title;
|
||||
title += menu?.text;
|
||||
} else {
|
||||
title += ' - ' + menu?.title;
|
||||
title += ' - ' + menu?.text;
|
||||
}
|
||||
|
||||
if (!hasSubmenu || !menu.submenu) return;
|
||||
@@ -241,6 +111,10 @@ const MainDrawer = ({
|
||||
setMainDrawerOpen(!mainDrawerOpen);
|
||||
};
|
||||
|
||||
if (!isPermitted) {
|
||||
return <PermissionNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={mainDrawerOpen}
|
||||
|
||||
+16
-12
@@ -7,6 +7,7 @@ import { Icon } from '@iconify/react';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import Button from '@/components/Button';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { AuthApi } from '@/services/api/auth';
|
||||
@@ -52,21 +53,24 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<div className='dropdown dropdown-end'>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
className='btn btn-ghost btn-circle avatar'
|
||||
>
|
||||
<div className='w-10 rounded-full border grid place-items-center'>
|
||||
<Icon icon='uil:user' width={40} height={40} />
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
trigger={
|
||||
<div className='btn btn-ghost btn-circle avatar'>
|
||||
<div className='w-10 rounded-full border flex justify-center items-center'>
|
||||
<Icon icon='uil:user' width={40} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'>
|
||||
}
|
||||
className={{
|
||||
content: 'w-52 mt-3',
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<MenuItem title='Logout' onClick={logoutClickHandler} />
|
||||
</Menu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+302
-212
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { ChangeEventHandler, ReactNode } from 'react';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
@@ -17,16 +19,18 @@ const PaginationButton = ({
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button
|
||||
className={cn(
|
||||
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square',
|
||||
'disabled:text-gray-700 disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-gray-50 disabled:active:translate-y-0'
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
|
||||
'disabled:text-primary disabled:pointer-events-auto! disabled:cursor-not-allowed! disabled:bg-primary/10 disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const EtcPaginationButton = ({
|
||||
@@ -48,7 +52,7 @@ const EtcPaginationButton = ({
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
className={cn(
|
||||
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square'
|
||||
'join-item btn btn-ghost p-2.5 rounded-lg! text-sm font-medium text-gray-500 aspect-square'
|
||||
)}
|
||||
>
|
||||
...
|
||||
@@ -57,7 +61,7 @@ const EtcPaginationButton = ({
|
||||
<div className='dropdown-content'>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className='menu bg-base-100 rounded-lg z-1 w-fit min-w-max max-h-64 p-1 shadow-sm mb-2 overflow-y-auto flex-nowrap'
|
||||
className='menu bg-base-100 rounded-lg! z-1 w-fit min-w-max max-h-64 p-1 shadow-sm mb-2 overflow-y-auto flex-nowrap'
|
||||
>
|
||||
{pages.map((pageNumber) => (
|
||||
<li key={pageNumber}>
|
||||
@@ -76,7 +80,7 @@ const EtcPaginationButton = ({
|
||||
<button
|
||||
disabled
|
||||
className={cn(
|
||||
'join-item btn btn-ghost p-2.5 rounded-lg text-sm font-medium text-gray-500 aspect-square'
|
||||
'join-item btn btn-ghost p-2.5 rounded-lg! text-sm font-medium text-gray-500 aspect-square'
|
||||
)}
|
||||
>
|
||||
...
|
||||
@@ -90,16 +94,20 @@ const Pagination = ({
|
||||
currentPage = 1,
|
||||
totalItems = 0,
|
||||
itemsPerPage = 10,
|
||||
rowOptions = [10, 20, 50, 100],
|
||||
onPageChange,
|
||||
onPrevPage = () => {},
|
||||
onNextPage = () => {},
|
||||
onRowChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
rowOptions?: number[];
|
||||
onPageChange: (pageNumber: number) => void;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
onRowChange?: (row: number) => void;
|
||||
}) => {
|
||||
const totalPages =
|
||||
Math.ceil(totalItems / itemsPerPage) === 0
|
||||
@@ -107,30 +115,139 @@ const Pagination = ({
|
||||
: Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
const pageChangeHandler = (pageNumber: number) => onPageChange(pageNumber);
|
||||
const firstPageClickHandler = () => onPageChange(1);
|
||||
const lastPageClickHandler = () => onPageChange(totalPages);
|
||||
|
||||
const rowChangeHandler: ChangeEventHandler<HTMLSelectElement> = (e) => {
|
||||
onRowChange?.(Number(e.target.value));
|
||||
};
|
||||
|
||||
const DisplayedRowCountSelect = () => (
|
||||
<div className='flex flex-row items-center gap-4'>
|
||||
<span className='text-sm font-medium text-base-content/50'>Showing</span>
|
||||
|
||||
<select
|
||||
defaultValue={itemsPerPage}
|
||||
onChange={rowChangeHandler}
|
||||
className='select select-xs w-fit pl-3 pr-7 text-base-content/50'
|
||||
>
|
||||
{rowOptions.map((rowOption, rowOptionIdx) => (
|
||||
<option
|
||||
key={rowOptionIdx}
|
||||
value={rowOption}
|
||||
className='text-base-content active:text-neutral-content'
|
||||
>
|
||||
{rowOption} Per page
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const GoToFirstPageButton = () => (
|
||||
<Button
|
||||
disabled={currentPage === 1}
|
||||
onClick={firstPageClickHandler}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className={cn(
|
||||
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
|
||||
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon='heroicons:chevron-double-left'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const PrevPageButton = () => (
|
||||
<Button
|
||||
disabled={currentPage === 1}
|
||||
onClick={onPrevPage}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className={cn(
|
||||
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
|
||||
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon='heroicons:chevron-left'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const GoToLastPageButton = () => (
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={lastPageClickHandler}
|
||||
className={cn(
|
||||
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
|
||||
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon='heroicons:chevron-double-right'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const NextPageButton = () => (
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={onNextPage}
|
||||
className={cn(
|
||||
'join-item w-10 h-10 grid place-items-center p-2.5 rounded-lg! text-sm font-semibold text-base-content/50 aspect-square',
|
||||
'disabled:bg-[initial]! disabled:text-base-content disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon='heroicons:chevron-right'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const PageInfo = () => (
|
||||
<span className='text-nowrap text-sm font-medium text-base-content/50'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='join w-full justify-between items-center gap-3'>
|
||||
<button
|
||||
disabled={currentPage === 1}
|
||||
onClick={onPrevPage}
|
||||
className={cn(
|
||||
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5',
|
||||
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon='uil:arrow-left'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>{' '}
|
||||
Previous
|
||||
</button>
|
||||
<div className='@container'>
|
||||
<div className='flex flex-row justify-center items-center'>
|
||||
<div className='hidden @md:block'>
|
||||
<DisplayedRowCountSelect />
|
||||
</div>
|
||||
|
||||
{totalPages <= 7 && (
|
||||
<div className='join-item join gap-0.5'>
|
||||
{range(1, totalPages).map((pageNumber) => (
|
||||
<div className='join w-full justify-end @md:justify-center items-center gap-0.5'>
|
||||
<div className='hidden @md:block'>
|
||||
<GoToFirstPageButton />
|
||||
</div>
|
||||
|
||||
<div className='hidden @md:block'>
|
||||
<PrevPageButton />
|
||||
</div>
|
||||
|
||||
{totalPages <= 7 &&
|
||||
range(1, totalPages).map((pageNumber) => (
|
||||
<PaginationButton
|
||||
key={pageNumber}
|
||||
content={pageNumber}
|
||||
@@ -138,195 +255,168 @@ const Pagination = ({
|
||||
onClick={() => pageChangeHandler(pageNumber)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 7 && (
|
||||
<div className='join-item join gap-0.5'>
|
||||
<PaginationButton
|
||||
content={1}
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => pageChangeHandler(1)}
|
||||
/>
|
||||
|
||||
{totalPages >= 2 &&
|
||||
(currentPage <= 3 || currentPage >= totalPages - 2) && (
|
||||
<PaginationButton
|
||||
content={2}
|
||||
disabled={currentPage === 2}
|
||||
onClick={() => pageChangeHandler(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 2 &&
|
||||
currentPage > 3 &&
|
||||
currentPage < totalPages - 2 && (
|
||||
<EtcPaginationButton
|
||||
startPage={2}
|
||||
endPage={currentPage - 2}
|
||||
onPageItemClick={pageChangeHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 3 &&
|
||||
(currentPage <= 4 || currentPage >= totalPages - 2) &&
|
||||
currentPage !== totalPages - 2 && (
|
||||
<PaginationButton
|
||||
content={3}
|
||||
disabled={currentPage === 3}
|
||||
onClick={() => pageChangeHandler(3)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 7 &&
|
||||
(currentPage <= 2 || currentPage >= totalPages - 2) && (
|
||||
<EtcPaginationButton
|
||||
startPage={
|
||||
currentPage <= 2
|
||||
? currentPage + 2
|
||||
: currentPage === totalPages - 2
|
||||
? 3
|
||||
: currentPage >= totalPages - 1
|
||||
? 4
|
||||
: 1
|
||||
}
|
||||
endPage={
|
||||
currentPage <= 2 || currentPage >= totalPages - 1
|
||||
? totalPages - 3
|
||||
: currentPage === totalPages - 2
|
||||
? totalPages - 4
|
||||
: 2
|
||||
}
|
||||
onPageItemClick={pageChangeHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 3 &&
|
||||
currentPage > 4 &&
|
||||
currentPage < totalPages - 1 && (
|
||||
<PaginationButton
|
||||
content={currentPage - 1}
|
||||
onClick={() => pageChangeHandler(currentPage - 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 7 &&
|
||||
currentPage > 3 &&
|
||||
currentPage < totalPages - 2 && (
|
||||
<PaginationButton content={currentPage} disabled />
|
||||
)}
|
||||
|
||||
{totalPages >= 5 &&
|
||||
currentPage > 2 &&
|
||||
currentPage < totalPages - 2 && (
|
||||
<PaginationButton
|
||||
content={currentPage + 1}
|
||||
onClick={() => pageChangeHandler(currentPage + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 5 &&
|
||||
(currentPage <= 2 || currentPage >= totalPages - 2) && (
|
||||
<PaginationButton
|
||||
content={totalPages - 2}
|
||||
disabled={currentPage === totalPages - 2}
|
||||
onClick={() => pageChangeHandler(totalPages - 2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 6 &&
|
||||
currentPage > 2 &&
|
||||
currentPage < totalPages - 3 && (
|
||||
<EtcPaginationButton
|
||||
startPage={
|
||||
currentPage <= 3
|
||||
? currentPage + 2
|
||||
: currentPage >= 4
|
||||
? currentPage + 2
|
||||
: 1
|
||||
}
|
||||
endPage={
|
||||
currentPage <= 3
|
||||
? totalPages - 2
|
||||
: currentPage >= 4
|
||||
? totalPages - 1
|
||||
: 0
|
||||
}
|
||||
onPageItemClick={pageChangeHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 6 &&
|
||||
(currentPage <= 3 || currentPage >= totalPages - 3) && (
|
||||
<PaginationButton
|
||||
content={totalPages - 1}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
onClick={() => pageChangeHandler(totalPages - 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 7 && (
|
||||
{totalPages > 7 && (
|
||||
<>
|
||||
<PaginationButton
|
||||
content={totalPages}
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => pageChangeHandler(totalPages)}
|
||||
content={1}
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => pageChangeHandler(1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={onNextPage}
|
||||
className={cn(
|
||||
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs hidden sm:flex justify-center items-center gap-1.5',
|
||||
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
{totalPages >= 2 &&
|
||||
(currentPage <= 3 || currentPage >= totalPages - 2) && (
|
||||
<PaginationButton
|
||||
content={2}
|
||||
disabled={currentPage === 2}
|
||||
onClick={() => pageChangeHandler(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 2 &&
|
||||
currentPage > 3 &&
|
||||
currentPage < totalPages - 2 && (
|
||||
<EtcPaginationButton
|
||||
startPage={2}
|
||||
endPage={currentPage - 2}
|
||||
onPageItemClick={pageChangeHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 3 &&
|
||||
(currentPage <= 4 || currentPage >= totalPages - 2) &&
|
||||
currentPage !== totalPages - 2 && (
|
||||
<PaginationButton
|
||||
content={3}
|
||||
disabled={currentPage === 3}
|
||||
onClick={() => pageChangeHandler(3)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 7 &&
|
||||
(currentPage <= 2 || currentPage >= totalPages - 2) && (
|
||||
<EtcPaginationButton
|
||||
startPage={
|
||||
currentPage <= 2
|
||||
? currentPage + 2
|
||||
: currentPage === totalPages - 2
|
||||
? 3
|
||||
: currentPage >= totalPages - 1
|
||||
? 4
|
||||
: 1
|
||||
}
|
||||
endPage={
|
||||
currentPage <= 2 || currentPage >= totalPages - 1
|
||||
? totalPages - 3
|
||||
: currentPage === totalPages - 2
|
||||
? totalPages - 4
|
||||
: 2
|
||||
}
|
||||
onPageItemClick={pageChangeHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 3 &&
|
||||
currentPage > 4 &&
|
||||
currentPage < totalPages - 1 && (
|
||||
<PaginationButton
|
||||
content={currentPage - 1}
|
||||
onClick={() => pageChangeHandler(currentPage - 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 7 &&
|
||||
currentPage > 3 &&
|
||||
currentPage < totalPages - 2 && (
|
||||
<PaginationButton content={currentPage} disabled />
|
||||
)}
|
||||
|
||||
{totalPages >= 5 &&
|
||||
currentPage > 2 &&
|
||||
currentPage < totalPages - 2 && (
|
||||
<PaginationButton
|
||||
content={currentPage + 1}
|
||||
onClick={() => pageChangeHandler(currentPage + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 5 &&
|
||||
(currentPage <= 2 || currentPage >= totalPages - 2) && (
|
||||
<PaginationButton
|
||||
content={totalPages - 2}
|
||||
disabled={currentPage === totalPages - 2}
|
||||
onClick={() => pageChangeHandler(totalPages - 2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 6 &&
|
||||
currentPage > 2 &&
|
||||
currentPage < totalPages - 3 && (
|
||||
<EtcPaginationButton
|
||||
startPage={
|
||||
currentPage <= 3
|
||||
? currentPage + 2
|
||||
: currentPage >= 4
|
||||
? currentPage + 2
|
||||
: 1
|
||||
}
|
||||
endPage={
|
||||
currentPage <= 3
|
||||
? totalPages - 2
|
||||
: currentPage >= 4
|
||||
? totalPages - 1
|
||||
: 0
|
||||
}
|
||||
onPageItemClick={pageChangeHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 6 &&
|
||||
(currentPage <= 3 || currentPage >= totalPages - 3) && (
|
||||
<PaginationButton
|
||||
content={totalPages - 1}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
onClick={() => pageChangeHandler(totalPages - 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalPages >= 7 && (
|
||||
<PaginationButton
|
||||
content={totalPages}
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => pageChangeHandler(totalPages)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Next{' '}
|
||||
<Icon
|
||||
icon='uil:arrow-right'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className='hidden @md:block'>
|
||||
<NextPageButton />
|
||||
</div>
|
||||
|
||||
<div className='hidden @md:block'>
|
||||
<GoToLastPageButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden @md:block'>
|
||||
<PageInfo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 mt-2 sm:hidden'>
|
||||
<button
|
||||
disabled={currentPage === 1}
|
||||
onClick={onPrevPage}
|
||||
className={cn(
|
||||
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5',
|
||||
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon='uil:arrow-left'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>{' '}
|
||||
Previous
|
||||
</button>
|
||||
<div className='flex @md:hidden flex-col justify-center items-end gap-2'>
|
||||
<div className='flex flex-row items-center gap-0.5'>
|
||||
<GoToFirstPageButton />
|
||||
<PrevPageButton />
|
||||
<NextPageButton />
|
||||
<GoToLastPageButton />
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={onNextPage}
|
||||
className={cn(
|
||||
'join-item btn btn-outline group px-3 py-2 text-sm font-semibold rounded-lg border border-gray-300 shadow-xs flex justify-center items-center gap-1.5',
|
||||
'disabled:bg-[initial]! disabled:text-gray-400 disabled:pointer-events-auto! disabled:cursor-not-allowed disabled:active:translate-y-0'
|
||||
)}
|
||||
>
|
||||
Next{' '}
|
||||
<Icon
|
||||
icon='uil:arrow-right'
|
||||
width={20}
|
||||
height={20}
|
||||
className='text-gray-400 group-disabled:text-gray-300'
|
||||
/>
|
||||
</button>
|
||||
<div className='flex flex-row items-center gap-4'>
|
||||
<DisplayedRowCountSelect />
|
||||
|
||||
<PageInfo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+177
-73
@@ -14,6 +14,7 @@ import {
|
||||
SortingState,
|
||||
OnChangeFn,
|
||||
Row,
|
||||
HeaderContext,
|
||||
} from '@tanstack/react-table';
|
||||
import { rankItem } from '@tanstack/match-sorter-utils';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -31,6 +32,9 @@ interface TableClassNames {
|
||||
tableBodyClassName?: string;
|
||||
bodyRowClassName?: string;
|
||||
bodyColumnClassName?: string;
|
||||
tableFooterClassName?: string;
|
||||
footerRowClassName?: string;
|
||||
footerColumnClassName?: string;
|
||||
paginationClassName?: string;
|
||||
}
|
||||
|
||||
@@ -38,6 +42,7 @@ export interface TableProps<TData extends object> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, unknown>[];
|
||||
pageSize?: number;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
totalItems?: number;
|
||||
page?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
@@ -52,6 +57,15 @@ export interface TableProps<TData extends object> {
|
||||
rowSelection?: Record<string, boolean>;
|
||||
setRowSelection?: OnChangeFn<Record<string, boolean>>;
|
||||
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
|
||||
renderFooter?: boolean;
|
||||
withCheckbox?: boolean;
|
||||
rowOptions?: number[];
|
||||
/**
|
||||
* Custom row renderer. Should return a complete <tr> element or null.
|
||||
* This gives full control over the row structure including colspan.
|
||||
* Return null to render the default row.
|
||||
*/
|
||||
renderCustomRow?: (row: Row<TData>) => ReactNode | null;
|
||||
}
|
||||
|
||||
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
|
||||
@@ -64,28 +78,36 @@ const emptyContentDefaultValue = (
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TABLE_DEFAULT_STYLING = {
|
||||
containerClassName: 'w-full mb-20',
|
||||
tableWrapperClassName:
|
||||
'overflow-x-auto border border-solid border-base-content/10 rounded-lg',
|
||||
tableClassName: 'font-inter w-full table-auto text-sm font-medium',
|
||||
tableHeaderClassName: '',
|
||||
headerRowClassName: '',
|
||||
headerColumnClassName:
|
||||
'px-4 py-3 border-base-content/10 text-base-content/50',
|
||||
tableBodyClassName: '',
|
||||
bodyRowClassName: 'border-t border-base-content/10',
|
||||
bodyColumnClassName: 'px-4 py-3 text-base-content',
|
||||
paginationClassName: '',
|
||||
tableFooterClassName: 'font-semibold border-base-content/10',
|
||||
footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10',
|
||||
footerColumnClassName: 'p-4 text-base-content whitespace-nowrap',
|
||||
};
|
||||
|
||||
const Table = <TData extends object>({
|
||||
data = [],
|
||||
columns = [],
|
||||
pageSize = 10,
|
||||
onPageSizeChange,
|
||||
totalItems,
|
||||
page,
|
||||
onPageChange,
|
||||
isLoading = false,
|
||||
fuzzySearchValue,
|
||||
onFuzzySearchValueChange,
|
||||
className = {
|
||||
containerClassName: '',
|
||||
tableWrapperClassName: '',
|
||||
tableClassName: '',
|
||||
tableHeaderClassName: '',
|
||||
headerRowClassName: '',
|
||||
headerColumnClassName: '',
|
||||
tableBodyClassName: '',
|
||||
bodyRowClassName: '',
|
||||
bodyColumnClassName: '',
|
||||
paginationClassName: '',
|
||||
},
|
||||
className = TABLE_DEFAULT_STYLING,
|
||||
emptyContent = emptyContentDefaultValue,
|
||||
sorting,
|
||||
setSorting,
|
||||
@@ -93,12 +115,21 @@ const Table = <TData extends object>({
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
enableRowSelection,
|
||||
renderFooter = false,
|
||||
withCheckbox = false,
|
||||
rowOptions = [10, 20, 50, 100],
|
||||
renderCustomRow,
|
||||
}: TableProps<TData>) => {
|
||||
const isServerSideTable =
|
||||
totalItems !== undefined &&
|
||||
page !== undefined &&
|
||||
onPageChange !== undefined;
|
||||
|
||||
const tableClassNames = {
|
||||
...TABLE_DEFAULT_STYLING,
|
||||
...className,
|
||||
};
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSize,
|
||||
@@ -191,77 +222,148 @@ const Table = <TData extends object>({
|
||||
}, [pageSize, setPageSize]);
|
||||
|
||||
return (
|
||||
<div className={className.containerClassName}>
|
||||
<div className={className.tableWrapperClassName}>
|
||||
<table className={className.tableClassName}>
|
||||
<thead className={className.tableHeaderClassName}>
|
||||
<div className={tableClassNames.containerClassName}>
|
||||
<div className={tableClassNames.tableWrapperClassName}>
|
||||
<table className={tableClassNames.tableClassName}>
|
||||
<thead className={tableClassNames.tableHeaderClassName}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className={className.headerRowClassName}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className={cn(
|
||||
header.column.getCanSort()
|
||||
? 'cursor-pointer select-none'
|
||||
: '',
|
||||
className.headerColumnClassName
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-1'>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className={tableClassNames.headerRowClassName}
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnRelativeDepth =
|
||||
header.depth - header.column.depth;
|
||||
if (
|
||||
!header.isPlaceholder &&
|
||||
columnRelativeDepth > 1 &&
|
||||
header.id === header.column.id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let rowSpan = 1;
|
||||
if (header.isPlaceholder) {
|
||||
const leafs = header.getLeafHeaders();
|
||||
rowSpan = leafs[leafs.length - 1].depth - header.depth;
|
||||
}
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
rowSpan={rowSpan}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className={cn(
|
||||
header.column.getCanSort()
|
||||
? 'cursor-pointer select-none'
|
||||
: '',
|
||||
{
|
||||
'first:w-9 first:pr-0': withCheckbox,
|
||||
},
|
||||
{
|
||||
'border-b': header.colSpan > 1,
|
||||
},
|
||||
tableClassNames.headerColumnClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('flex items-center gap-1 min-h-full', {
|
||||
'justify-center': header.colSpan > 1,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
|
||||
{header.column.getCanSort() && (
|
||||
<div className='flex items-center'>
|
||||
<Icon
|
||||
icon='lucide:arrow-up'
|
||||
width={12}
|
||||
height={12}
|
||||
className={cn(
|
||||
'transition-all ease-in-out duration-200',
|
||||
header.column.getIsSorted() === 'asc'
|
||||
? 'text-black'
|
||||
: 'text-black/30'
|
||||
)}
|
||||
/>
|
||||
<Icon
|
||||
icon='lucide:arrow-down'
|
||||
width={12}
|
||||
height={12}
|
||||
className={cn(
|
||||
'transition-all ease-in-out duration-200',
|
||||
header.column.getIsSorted() === 'desc'
|
||||
? 'text-black'
|
||||
: 'text-black/30'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{header.column.getCanSort() && (
|
||||
<div className='w-4 h-4 relative flex flex-col items-center'>
|
||||
<Icon
|
||||
icon='heroicons:chevron-up-16-solid'
|
||||
width={18}
|
||||
height={18}
|
||||
className={cn(
|
||||
'absolute -top-1',
|
||||
'transition-all ease-in-out duration-200',
|
||||
header.column.getIsSorted() === 'asc'
|
||||
? 'text-black'
|
||||
: 'text-black/30'
|
||||
)}
|
||||
/>
|
||||
<Icon
|
||||
icon='heroicons:chevron-down-16-solid'
|
||||
width={18}
|
||||
height={18}
|
||||
className={cn(
|
||||
'absolute -bottom-1.5',
|
||||
'transition-all ease-in-out duration-200',
|
||||
header.column.getIsSorted() === 'desc'
|
||||
? 'text-black'
|
||||
: 'text-black/30'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
|
||||
<tbody className={className.tableBodyClassName}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className={className.bodyRowClassName}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className={className.bodyColumnClassName}>
|
||||
{!isLoading &&
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<tbody className={tableClassNames.tableBodyClassName}>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const customRowContent = renderCustomRow?.(row);
|
||||
|
||||
{isLoading && <div className='skeleton w-full h-4' />}
|
||||
if (customRowContent) {
|
||||
return renderCustomRow?.(row);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={row.id} className={tableClassNames.bodyRowClassName}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
{ 'first:w-9 first:pr-0': withCheckbox },
|
||||
tableClassNames.bodyColumnClassName
|
||||
)}
|
||||
>
|
||||
{!isLoading &&
|
||||
flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
|
||||
{isLoading && <div className='skeleton w-full h-4' />}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className={cn(tableClassNames.tableFooterClassName)}>
|
||||
{renderFooter && (
|
||||
<tr className={cn(tableClassNames.footerRowClassName)}>
|
||||
{table.getAllLeafColumns().map((column) => (
|
||||
<td
|
||||
key={column.id}
|
||||
className={cn(
|
||||
{ 'first:w-9 first:pr-0': withCheckbox },
|
||||
tableClassNames.footerColumnClassName
|
||||
)}
|
||||
>
|
||||
{column.columnDef.footer &&
|
||||
flexRender(column.columnDef.footer, {
|
||||
column,
|
||||
header: column.columnDef,
|
||||
table,
|
||||
} as HeaderContext<TData, unknown>)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +372,7 @@ const Table = <TData extends object>({
|
||||
emptyContent}
|
||||
|
||||
{data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && (
|
||||
<div className={cn('mt-5', className.paginationClassName)}>
|
||||
<div className={cn('mt-5', tableClassNames.paginationClassName)}>
|
||||
<Pagination
|
||||
totalItems={isServerSideTable ? totalItems : table.getRowCount()}
|
||||
itemsPerPage={table.getState().pagination.pageSize}
|
||||
@@ -282,6 +384,8 @@ const Table = <TData extends object>({
|
||||
onPrevPage={prevPageClickHandler}
|
||||
onNextPage={nextPageClickHandler}
|
||||
onPageChange={pageChangeHandler}
|
||||
rowOptions={rowOptions}
|
||||
onRowChange={onPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+13
-6
@@ -21,6 +21,7 @@ export interface TabsProps
|
||||
className?:
|
||||
| string
|
||||
| {
|
||||
container?: string;
|
||||
wrapper?: string;
|
||||
tab?: string;
|
||||
content?: string;
|
||||
@@ -53,10 +54,14 @@ const Tabs = ({
|
||||
onTabChange?.(tabId);
|
||||
};
|
||||
|
||||
const { wrapper: wrapperClassName, tab: tabClassName } =
|
||||
typeof className === 'object'
|
||||
? className
|
||||
: { wrapper: className, tab: undefined };
|
||||
const {
|
||||
container: containerClassName,
|
||||
wrapper: wrapperClassName,
|
||||
tab: tabClassName,
|
||||
content: contentClassName,
|
||||
} = typeof className === 'object'
|
||||
? className
|
||||
: { wrapper: className, tab: undefined };
|
||||
|
||||
const getTabsClasses = () => {
|
||||
const variantClasses: Record<string, string> = {
|
||||
@@ -104,7 +109,7 @@ const Tabs = ({
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full',
|
||||
typeof className === 'string' ? className : undefined
|
||||
typeof className === 'string' ? className : containerClassName
|
||||
)}
|
||||
>
|
||||
<div role='tablist' className={getTabsClasses()}>
|
||||
@@ -121,7 +126,9 @@ const Tabs = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeContent && <div className='mt-4'>{activeContent}</div>}
|
||||
{activeContent && (
|
||||
<div className={cn('mt-4', contentClassName)}>{activeContent}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { ReactNode, useState, useRef } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
trigger?: string;
|
||||
content?: string;
|
||||
};
|
||||
align?: 'start' | 'center' | 'end';
|
||||
direction?: 'top' | 'bottom' | 'left' | 'right';
|
||||
hover?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
close?: boolean;
|
||||
controlled?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown = ({
|
||||
trigger,
|
||||
children,
|
||||
className,
|
||||
align,
|
||||
direction,
|
||||
hover,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
close,
|
||||
controlled = false,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!controlled) {
|
||||
const newState = !isOpen;
|
||||
setIsOpen(newState);
|
||||
}
|
||||
};
|
||||
|
||||
const getWrapperClasses = () => {
|
||||
const openState = controlled ? open : isOpen;
|
||||
|
||||
return cn(
|
||||
'dropdown',
|
||||
{
|
||||
'dropdown-start': align === 'start',
|
||||
'dropdown-center': align === 'center',
|
||||
'dropdown-end': align === 'end',
|
||||
'dropdown-top': direction === 'top',
|
||||
'dropdown-bottom': direction === 'bottom',
|
||||
'dropdown-left': direction === 'left',
|
||||
'dropdown-right': direction === 'right',
|
||||
'dropdown-hover': hover,
|
||||
'dropdown-open': openState && !close,
|
||||
'dropdown-close': close,
|
||||
},
|
||||
className?.wrapper
|
||||
);
|
||||
};
|
||||
|
||||
const getTriggerClasses = () => {
|
||||
return cn(className?.trigger);
|
||||
};
|
||||
|
||||
const getContentClasses = () => {
|
||||
return cn(
|
||||
'dropdown-content z-[9999] shadow-sm bg-base-100 rounded-box',
|
||||
className?.content
|
||||
);
|
||||
};
|
||||
|
||||
if (controlled) {
|
||||
return (
|
||||
<div className={getWrapperClasses()}>
|
||||
{trigger}
|
||||
{open && !close && (
|
||||
<div tabIndex={-1} className={getContentClasses()}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={getWrapperClasses()}>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
className={getTriggerClasses()}
|
||||
onClick={toggleDropdown}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
{!close && (
|
||||
<div tabIndex={-1} className={getContentClasses()}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
@@ -0,0 +1,12 @@
|
||||
const PermissionNotFound = () => {
|
||||
return (
|
||||
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
|
||||
<h2 className='text-2xl font-bold text-error'>Permission Not Found</h2>
|
||||
<p className='text-gray-600 text-center'>
|
||||
You do not have permission to access this page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionNotFound;
|
||||
@@ -1,56 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||
import { AuthApi } from '@/services/api/auth';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
|
||||
import { AxiosError } from 'axios';
|
||||
import { redirectToSSO } from '@/lib/auth-helper';
|
||||
|
||||
interface RequireAuthProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
const router = useRouter();
|
||||
const { setUser, setIsLoadingUser } = useAuth();
|
||||
const { user, setUser, setIsLoadingUser } = useAuth();
|
||||
|
||||
const {
|
||||
data: userResponse,
|
||||
isLoading: isLoadingUserResponse,
|
||||
error: userErrorResponse,
|
||||
} = useSWRImmutable<
|
||||
} = useSWR<
|
||||
GetMeResponse & { ok?: boolean },
|
||||
AxiosError<BaseApiResponse>,
|
||||
SWRHttpKey
|
||||
>('/sso/userinfo', httpClientFetcher, {
|
||||
shouldRetryOnError: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingUser(isLoadingUserResponse);
|
||||
}, [isLoadingUserResponse, setIsLoadingUser]);
|
||||
// refresh every 12 minutes
|
||||
refreshInterval: 12 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isResponseSuccess(userResponse)) {
|
||||
setUser(userResponse.data);
|
||||
} else if (
|
||||
isResponseError(userErrorResponse?.response?.data) &&
|
||||
typeof window !== 'undefined'
|
||||
) {
|
||||
router.replace(
|
||||
`${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`
|
||||
);
|
||||
}
|
||||
}, [userResponse, userErrorResponse, setIsLoadingUser, setUser]);
|
||||
}, [userResponse, setUser]);
|
||||
|
||||
if (isLoadingUserResponse && !userResponse && !userErrorResponse) {
|
||||
// Explicitly handle 401 redirect from the component level
|
||||
useEffect(() => {
|
||||
if (
|
||||
isResponseError(userResponse) &&
|
||||
userErrorResponse?.response?.status === 401
|
||||
) {
|
||||
// Clear cache to prevent stale data from rendering children
|
||||
// mutate('/sso/userinfo', undefined, { revalidate: false }); // Optional: if using global mutate
|
||||
setUser(undefined);
|
||||
redirectToSSO();
|
||||
}
|
||||
}, [userErrorResponse, setUser, userResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingUser(isLoadingUserResponse);
|
||||
}, [isLoadingUserResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(
|
||||
async () => {
|
||||
await AuthApi.refresh();
|
||||
},
|
||||
12 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshUserSession = async () => {
|
||||
await AuthApi.refresh();
|
||||
};
|
||||
|
||||
refreshUserSession();
|
||||
}, []);
|
||||
|
||||
if (
|
||||
(isLoadingUserResponse && !userResponse && !userErrorResponse) ||
|
||||
(!userResponse && !userErrorResponse)
|
||||
) {
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
@@ -58,7 +86,22 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <>{isResponseSuccess(userResponse) && children}</>;
|
||||
if (!isLoadingUserResponse && userErrorResponse) {
|
||||
return (
|
||||
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
|
||||
<h2 className='text-2xl font-bold text-error'>Authentication Failed</h2>
|
||||
<p className='text-gray-600'>
|
||||
Please try refreshing the page or contact support if the problem
|
||||
persists.
|
||||
</p>
|
||||
<button className='btn btn-primary' onClick={() => redirectToSSO()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{isResponseSuccess(userResponse) && user && children}</>;
|
||||
};
|
||||
|
||||
export default RequireAuth;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
|
||||
interface RequirePermissionProps {
|
||||
children: React.ReactNode;
|
||||
permissions: string | string[];
|
||||
}
|
||||
|
||||
const RequirePermission = ({
|
||||
children,
|
||||
permissions,
|
||||
}: RequirePermissionProps) => {
|
||||
const { permissionCheck } = useAuth();
|
||||
|
||||
const isPermitted =
|
||||
typeof permissions === 'string'
|
||||
? permissionCheck(permissions)
|
||||
: permissions.some((permission) => permissionCheck(permission));
|
||||
|
||||
if (!isPermitted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default RequirePermission;
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Link from 'next/link';
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface DrawerHeaderProps {
|
||||
// Left side props
|
||||
leftIcon?: string;
|
||||
leftIconSize?: number;
|
||||
leftIconHref?: string;
|
||||
leftIconOnClick?: () => void;
|
||||
leftIconClassName?: string;
|
||||
|
||||
// Subtitle/label props
|
||||
subtitle?: string | ReactNode;
|
||||
subtitleClassName?: string;
|
||||
|
||||
// Right side actions (children)
|
||||
children?: ReactNode;
|
||||
|
||||
// Container props
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
const DrawerHeader = ({
|
||||
leftIcon = 'mdi:close',
|
||||
leftIconSize = 24,
|
||||
leftIconHref,
|
||||
leftIconOnClick,
|
||||
leftIconClassName,
|
||||
subtitle,
|
||||
subtitleClassName,
|
||||
children,
|
||||
className,
|
||||
showDivider = true,
|
||||
}: DrawerHeaderProps) => {
|
||||
const renderLeftIcon = () => {
|
||||
const iconElement = (
|
||||
<Icon
|
||||
icon={leftIcon}
|
||||
width={leftIconSize}
|
||||
height={leftIconSize}
|
||||
className={cn('cursor-pointer', leftIconClassName)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (leftIconHref) {
|
||||
return (
|
||||
<Link href={leftIconHref} className='hover:text-gray-400'>
|
||||
{iconElement}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (leftIconOnClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={leftIconOnClick}
|
||||
className='hover:text-gray-400 bg-transparent border-none p-0'
|
||||
>
|
||||
{iconElement}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return iconElement;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row justify-between items-center px-4 pt-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left Side */}
|
||||
<div className='flex flex-row h-full gap-2 items-center'>
|
||||
{renderLeftIcon()}
|
||||
|
||||
{showDivider && subtitle && (
|
||||
<div className='divider divider-horizontal p-0 m-0'></div>
|
||||
)}
|
||||
|
||||
{subtitle && (
|
||||
<div className={cn('text-sm text-neutral', subtitleClassName)}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side Actions */}
|
||||
{children && (
|
||||
<div className='flex flex-row gap-3 justify-end items-center'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawerHeader;
|
||||
@@ -0,0 +1,49 @@
|
||||
import Alert from '@/components/Alert';
|
||||
import Button from '@/components/Button';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Alert Unique Error List
|
||||
* @param formErrorList - Array of error messages
|
||||
* @param onClose - Function to close the alert
|
||||
*/
|
||||
const AlertErrorList = ({
|
||||
formErrorList,
|
||||
onClose,
|
||||
}: {
|
||||
formErrorList: string[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
if (formErrorList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'>
|
||||
<div className='flex justify-between items-center gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon icon='material-symbols:error-outline' width={24} height={24} />
|
||||
<span className='font-semibold'>
|
||||
Terdapat {formErrorList.length} error pada form:
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant='link'
|
||||
className='ml-auto p-0 w-fit text-white'
|
||||
color='none'
|
||||
>
|
||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<ul className='list-disc list-inside pl-8 space-y-1 w-full'>
|
||||
{formErrorList.map((error, index) => (
|
||||
<li key={index} className='text-sm'>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertErrorList;
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { HTMLProps, useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { Size } from '@/types/theme';
|
||||
|
||||
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
|
||||
interface CheckboxInputProps extends Omit<HTMLProps<HTMLInputElement>, 'size'> {
|
||||
name: string;
|
||||
label?: string;
|
||||
indeterminate?: boolean;
|
||||
@@ -16,6 +17,7 @@ interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
|
||||
isError?: boolean;
|
||||
isValid?: boolean;
|
||||
errorMessage?: string;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const CheckboxInput = ({
|
||||
@@ -27,10 +29,19 @@ const CheckboxInput = ({
|
||||
isValid,
|
||||
isError,
|
||||
errorMessage,
|
||||
size = 'sm',
|
||||
...rest
|
||||
}: CheckboxInputProps) => {
|
||||
const ref = useRef<HTMLInputElement>(null!);
|
||||
|
||||
const checkboxBaseClassName = cn('checkbox cursor-pointer rounded-md', {
|
||||
'checkbox-xs': size === 'xs',
|
||||
'checkbox-sm': size === 'sm',
|
||||
'checkbox-md': size === 'md',
|
||||
'checkbox-lg': size === 'lg',
|
||||
'checkbox-xl': size === 'xl',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof indeterminate === 'boolean') {
|
||||
ref.current.indeterminate = !rest.checked && indeterminate;
|
||||
@@ -53,7 +64,7 @@ const CheckboxInput = ({
|
||||
id={name}
|
||||
name={name}
|
||||
className={cn(
|
||||
'checkbox cursor-pointer',
|
||||
checkboxBaseClassName,
|
||||
{
|
||||
'border-error': isError,
|
||||
'border-success': isValid,
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { cn, formatDate } from '@/lib/helper';
|
||||
import Modal, { useModal } from '../Modal';
|
||||
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
|
||||
import 'react-day-picker/dist/style.css';
|
||||
import Button from '../Button';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
export interface DateInputProps {
|
||||
label?: string;
|
||||
|
||||
@@ -24,6 +24,11 @@ const DebouncedTextInput = (props: DebouncedTextInputProps) => {
|
||||
setInternalChangeEvent(e);
|
||||
};
|
||||
|
||||
// Sync internal value with external value prop changes (e.g., from reset)
|
||||
useEffect(() => {
|
||||
setInternalValue(props.value);
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedChangeEvent) {
|
||||
onChange?.(debouncedChangeEvent);
|
||||
|
||||
@@ -33,6 +33,7 @@ const FileInput = ({
|
||||
isError,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
required = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
@@ -56,6 +57,13 @@ const FileInput = ({
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<>
|
||||
<span className='tooltip tooltip-error' data-tip='required'>
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, ReactNode } from 'react';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface RadioOption {
|
||||
@@ -8,37 +13,74 @@ export interface RadioOption {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface RadioInputProps {
|
||||
label?: string;
|
||||
bottomLabel?: string;
|
||||
// DaisyUI Radio Colors
|
||||
export type RadioColor =
|
||||
| 'neutral'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'accent'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'error';
|
||||
|
||||
// DaisyUI Radio Sizes
|
||||
export type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
// Context untuk RadioGroup
|
||||
interface RadioGroupContextValue {
|
||||
name: string;
|
||||
value?: string;
|
||||
options: RadioOption[];
|
||||
variant?: string;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
label?: string;
|
||||
radioWrapper?: string;
|
||||
radio?: string;
|
||||
};
|
||||
isError?: boolean;
|
||||
isValid?: boolean;
|
||||
errorMessage?: string;
|
||||
required?: boolean;
|
||||
color?: RadioColor;
|
||||
size?: RadioSize;
|
||||
disabled?: boolean;
|
||||
startAdornment?: ReactNode;
|
||||
endAdornment?: ReactNode;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const RadioInput = ({
|
||||
const RadioGroupContext = createContext<RadioGroupContextValue | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const useRadioGroup = () => {
|
||||
const context = useContext(RadioGroupContext);
|
||||
if (!context) {
|
||||
throw new Error('RadioGroupItem must be used within RadioGroup');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// RadioGroup Component
|
||||
export interface RadioGroupProps {
|
||||
label?: string;
|
||||
bottomLabel?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
options?: RadioOption[];
|
||||
color?: RadioColor;
|
||||
size?: RadioSize;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
label?: string;
|
||||
radioWrapper?: string;
|
||||
};
|
||||
isError?: boolean;
|
||||
errorMessage?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const RadioGroup = ({
|
||||
label,
|
||||
bottomLabel,
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
variant = 'radio-primary',
|
||||
color = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
isError,
|
||||
errorMessage,
|
||||
@@ -46,68 +88,125 @@ const RadioInput = ({
|
||||
disabled = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: RadioInputProps) => {
|
||||
return (
|
||||
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
|
||||
{/* Label atas */}
|
||||
{label && (
|
||||
<label
|
||||
className={cn(
|
||||
'w-full text-sm font-normal leading-5',
|
||||
{ 'text-error': isError },
|
||||
className?.label
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<span className='text-error ml-1' title='required'>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
children,
|
||||
}: RadioGroupProps) => {
|
||||
const contextValue: RadioGroupContextValue = {
|
||||
name,
|
||||
value,
|
||||
color,
|
||||
size,
|
||||
disabled,
|
||||
onChange,
|
||||
onBlur,
|
||||
};
|
||||
|
||||
{/* Daftar opsi radio */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row flex-wrap gap-4 items-center',
|
||||
className?.radioWrapper
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
return (
|
||||
<RadioGroupContext.Provider value={contextValue}>
|
||||
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
|
||||
{/* Label atas */}
|
||||
{label && (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'flex flex-row items-center gap-2 cursor-pointer',
|
||||
disabled && 'opacity-60 cursor-not-allowed'
|
||||
'w-full text-sm font-normal leading-5',
|
||||
{ 'text-error': isError },
|
||||
className?.label
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='radio'
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
className={cn('radio', variant, className?.radio)}
|
||||
/>
|
||||
<span className='text-sm'>{option.label}</span>
|
||||
{label}
|
||||
{required && (
|
||||
<span className='text-error ml-1' title='required'>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Daftar opsi radio */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row flex-wrap gap-4 items-center',
|
||||
className?.radioWrapper
|
||||
)}
|
||||
>
|
||||
{/* Jika options diberikan, render otomatis */}
|
||||
{options &&
|
||||
options.map((option) => (
|
||||
<RadioGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Atau gunakan children untuk custom rendering */}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Label bawah */}
|
||||
{!isError && bottomLabel && (
|
||||
<p className='text-sm opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
|
||||
{/* Pesan error */}
|
||||
{isError && errorMessage && (
|
||||
<p className='text-sm text-error'>{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label bawah */}
|
||||
{!isError && bottomLabel && (
|
||||
<p className='text-sm opacity-60'>{bottomLabel}</p>
|
||||
)}
|
||||
|
||||
{/* Pesan error */}
|
||||
{isError && errorMessage && (
|
||||
<p className='text-sm text-error'>{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroupContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadioInput;
|
||||
// RadioGroupItem Component
|
||||
export interface RadioGroupItemProps {
|
||||
value: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
color?: RadioColor;
|
||||
size?: RadioSize;
|
||||
}
|
||||
|
||||
export const RadioGroupItem = ({
|
||||
value,
|
||||
label,
|
||||
className,
|
||||
disabled: itemDisabled,
|
||||
color: itemColor,
|
||||
size: itemSize,
|
||||
}: RadioGroupItemProps) => {
|
||||
const {
|
||||
name,
|
||||
value: groupValue,
|
||||
color: groupColor,
|
||||
size: groupSize,
|
||||
disabled: groupDisabled,
|
||||
onChange,
|
||||
onBlur,
|
||||
} = useRadioGroup();
|
||||
|
||||
const isDisabled = itemDisabled ?? groupDisabled;
|
||||
const radioColor = itemColor ?? groupColor;
|
||||
const radioSize = itemSize ?? groupSize;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex flex-row items-center gap-2 cursor-pointer',
|
||||
isDisabled && 'opacity-60 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='radio'
|
||||
name={name}
|
||||
value={value}
|
||||
checked={groupValue === value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
disabled={isDisabled}
|
||||
className={cn('radio', `radio-${radioColor}`, `radio-${radioSize}`)}
|
||||
/>
|
||||
{label && <span className='text-sm'>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
import { Size } from '@/types/theme';
|
||||
|
||||
interface MenuProps {
|
||||
children?: ReactNode;
|
||||
size?: Size;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Menu = ({ children, className }: MenuProps) => {
|
||||
return (
|
||||
<ul className={cn('menu w-full p-0 gap-0.5', className)}>{children}</ul>
|
||||
);
|
||||
const Menu = ({
|
||||
children,
|
||||
size = 'md',
|
||||
direction = 'vertical',
|
||||
className,
|
||||
}: MenuProps) => {
|
||||
const menuBaseClassName = cn('menu w-full', {
|
||||
'menu-xs': size === 'xs',
|
||||
'menu-sm': size === 'sm',
|
||||
'menu-md': size === 'md',
|
||||
'menu-lg': size === 'lg',
|
||||
'menu-xl': size === 'xl',
|
||||
'menu-vertical': direction === 'vertical',
|
||||
'menu-horizontal': direction === 'horizontal',
|
||||
});
|
||||
|
||||
return <ul className={cn(menuBaseClassName, className)}>{children}</ul>;
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
|
||||
@@ -8,6 +8,7 @@ interface MenuItemProps {
|
||||
href?: string;
|
||||
icon?: string;
|
||||
active?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -17,6 +18,7 @@ const MenuItem = ({
|
||||
href,
|
||||
icon,
|
||||
active = false,
|
||||
isLoading = false,
|
||||
className,
|
||||
onClick,
|
||||
}: MenuItemProps) => {
|
||||
@@ -50,17 +52,28 @@ const MenuItem = ({
|
||||
|
||||
return (
|
||||
<li>
|
||||
{href && (
|
||||
{!isLoading && href && (
|
||||
<Link href={href} className={menuItemBaseClassName}>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!href && (
|
||||
{!isLoading && !href && (
|
||||
<button className={menuItemBaseClassName} onClick={onClick}>
|
||||
{menuItemContent}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<button className={menuItemBaseClassName}>
|
||||
<span
|
||||
className={cn('loading loading-dots loading-md mx-auto', {
|
||||
'text-gray-400': !active,
|
||||
'text-black': active,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,13 @@ import Button, { ButtonProps } from '@/components/Button';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export type IconPosition = 'left' | 'center' | 'right';
|
||||
|
||||
export interface ConfirmationModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
type?: 'info' | 'success' | 'error';
|
||||
text?: string;
|
||||
subtitleText?: string;
|
||||
closeOnBackdrop?: boolean;
|
||||
primaryButton?: ButtonProps & {
|
||||
text?: string;
|
||||
@@ -24,17 +27,84 @@ export interface ConfirmationModalProps {
|
||||
modalBox?: string;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
iconSize?: number;
|
||||
iconPosition?: IconPosition;
|
||||
}
|
||||
|
||||
const iconConfig = {
|
||||
info: {
|
||||
icon: 'material-symbols:info-outline-rounded',
|
||||
iconClassName: 'text-info-content',
|
||||
bgClassName: 'bg-info',
|
||||
outerRingClassName: 'bg-info/20',
|
||||
borderClassName: 'border-info',
|
||||
},
|
||||
success: {
|
||||
icon: 'heroicons:check',
|
||||
iconClassName: 'text-white',
|
||||
bgClassName: 'bg-[#00D390]',
|
||||
outerRingClassName: 'bg-[#00D3901F]',
|
||||
borderClassName: 'border-[#CCF7EB]',
|
||||
},
|
||||
error: {
|
||||
icon: 'solar:danger-triangle-linear',
|
||||
iconClassName: 'text-error-content',
|
||||
bgClassName: 'bg-[#f03338]',
|
||||
outerRingClassName: 'bg-[#f3cdcd]',
|
||||
borderClassName: 'border-[#fff0ef]',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ConfirmationModalIcon = ({
|
||||
type,
|
||||
size = 24,
|
||||
}: {
|
||||
type: 'info' | 'success' | 'error';
|
||||
size?: number;
|
||||
}) => {
|
||||
const config = iconConfig[type];
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center p-2'>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full border-4 p-1',
|
||||
config.outerRingClassName,
|
||||
config.borderClassName
|
||||
)}
|
||||
>
|
||||
<div className={cn('rounded-full p-1', config.outerRingClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full p-3 flex items-center justify-center',
|
||||
config.bgClassName
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={config.icon}
|
||||
width={size}
|
||||
height={size}
|
||||
className={config.iconClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmationModal = ({
|
||||
ref,
|
||||
type = 'info',
|
||||
text,
|
||||
subtitleText,
|
||||
closeOnBackdrop,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
className,
|
||||
children,
|
||||
iconSize = 32,
|
||||
iconPosition = 'center',
|
||||
}: ConfirmationModalProps) => {
|
||||
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
|
||||
|
||||
@@ -55,47 +125,44 @@ const ConfirmationModal = ({
|
||||
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'
|
||||
/>
|
||||
)}
|
||||
{iconPosition === 'center' ? (
|
||||
<>
|
||||
<div className='w-fit mx-auto'>
|
||||
<ConfirmationModalIcon type={type} size={iconSize} />
|
||||
</div>
|
||||
|
||||
{type === 'success' && (
|
||||
<Icon
|
||||
icon='qlementine-icons:success-12'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-success-content'
|
||||
/>
|
||||
)}
|
||||
<p className='text-center font-medium'>
|
||||
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||
</p>
|
||||
|
||||
{type === 'error' && (
|
||||
<Icon
|
||||
icon='solar:danger-triangle-linear'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-error-content'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{subtitleText && (
|
||||
<p className='text-center text-sm text-gray-400'>
|
||||
{subtitleText}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn('flex flex-row items-center gap-4', {
|
||||
'flex-row': iconPosition === 'left',
|
||||
'flex-row-reverse': iconPosition === 'right',
|
||||
})}
|
||||
>
|
||||
<div className='w-fit'>
|
||||
<ConfirmationModalIcon type={type} size={iconSize} />
|
||||
</div>
|
||||
|
||||
<p className='text-center font-medium'>
|
||||
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||
</p>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<p className='font-medium'>
|
||||
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||
</p>
|
||||
|
||||
{subtitleText && (
|
||||
<p className='text-sm text-gray-400'>{subtitleText}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className='w-full'>{children}</div>}
|
||||
|
||||
@@ -103,7 +170,7 @@ const ConfirmationModal = ({
|
||||
{secondaryButton && secondaryButton.text && (
|
||||
<Button
|
||||
{...secondaryButton}
|
||||
variant='ghost'
|
||||
variant='outline'
|
||||
color={secondaryButton?.color}
|
||||
isLoading={secondaryButton?.isLoading}
|
||||
disabled={
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import Link from 'next/link';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { cn, isPathActive } from '@/lib/helper';
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
|
||||
export interface SidebarMenuItem {
|
||||
type?: 'item' | 'title';
|
||||
text: string;
|
||||
link: string;
|
||||
icon?: string;
|
||||
submenu?: SidebarMenuItem[];
|
||||
permission?: string[];
|
||||
}
|
||||
|
||||
interface SidebarMenuItemProps {
|
||||
item: SidebarMenuItem;
|
||||
activeLink: string;
|
||||
}
|
||||
|
||||
interface SidebarMenuProps {
|
||||
menu: SidebarMenuItem[];
|
||||
activeLink: string;
|
||||
}
|
||||
|
||||
const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
|
||||
const { permissionCheck } = useAuth();
|
||||
const isItemActive = isPathActive(activeLink, item.link);
|
||||
|
||||
const isUserPermitted = item.permission
|
||||
? item.permission?.some((permissionName) => permissionCheck(permissionName))
|
||||
: true;
|
||||
|
||||
if (!isUserPermitted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const menuItemWithoutSubmenu = (
|
||||
<li>
|
||||
<Link
|
||||
href={item.link}
|
||||
className={cn(
|
||||
{
|
||||
'menu-active border-2 border-solid border-base-300': isItemActive,
|
||||
},
|
||||
'px-3 py-1.5'
|
||||
)}
|
||||
>
|
||||
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
|
||||
|
||||
<span className='text-base'>{item.text}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
if (!item.submenu || item.submenu.length === 0) {
|
||||
return menuItemWithoutSubmenu;
|
||||
}
|
||||
|
||||
const menuItemWithSubmenu = (
|
||||
<li>
|
||||
<details open={isItemActive}>
|
||||
<summary
|
||||
className={cn({
|
||||
'text-primary': isItemActive,
|
||||
})}
|
||||
>
|
||||
{item.icon && <Icon icon={item.icon} width={20} height={20} />}
|
||||
|
||||
<span className='text-base'>{item.text}</span>
|
||||
</summary>
|
||||
|
||||
<ul>
|
||||
{item.submenu.map((submenuItem, submenuIdx) => (
|
||||
<SidebarMenuItem
|
||||
key={`submenu#${submenuIdx}`}
|
||||
item={submenuItem}
|
||||
activeLink={activeLink}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
|
||||
return menuItemWithSubmenu;
|
||||
};
|
||||
|
||||
const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => {
|
||||
return (
|
||||
<Menu>
|
||||
{menu.map((menuItem, menuIdx) => {
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={menuIdx}
|
||||
item={menuItem}
|
||||
activeLink={activeLink}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
@@ -144,33 +144,45 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => {
|
||||
|
||||
export const formatGroupedApprovalsToApprovalSteps = (
|
||||
approvalLine: ApprovalLine,
|
||||
groupedApprovals: BaseGroupedApproval[],
|
||||
latestApproval: BaseApproval
|
||||
groupedApprovals: BaseGroupedApproval[] | undefined,
|
||||
latestApproval: BaseApproval | undefined
|
||||
): ApprovalStepsProps['approvals'] => {
|
||||
const formattedApprovalSteps: ApprovalStepsProps['approvals'] =
|
||||
approvalLine.map((approvalLineItem) => {
|
||||
const approvalGroup = groupedApprovals.find(
|
||||
const approvalGroup = groupedApprovals?.find(
|
||||
(approvalGroupItem) =>
|
||||
approvalGroupItem.step_number === approvalLineItem.step_number
|
||||
);
|
||||
|
||||
const currentStepNumber = approvalLineItem.step_number;
|
||||
const lastStepNumber =
|
||||
groupedApprovals[groupedApprovals.length - 1]?.step_number;
|
||||
groupedApprovals?.[groupedApprovals.length - 1]?.step_number;
|
||||
|
||||
const isLatestApprovalRejected = latestApproval.action === 'REJECTED';
|
||||
const isLatestApprovalRejected = latestApproval?.action === 'REJECTED';
|
||||
|
||||
if (!approvalGroup && currentStepNumber <= lastStepNumber) {
|
||||
throw new Error(
|
||||
`Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
|
||||
);
|
||||
// Only throw error if we have a valid lastStepNumber to compare against
|
||||
if (
|
||||
!approvalGroup &&
|
||||
lastStepNumber !== undefined &&
|
||||
currentStepNumber <= lastStepNumber
|
||||
) {
|
||||
// throw new Error(
|
||||
// `Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
|
||||
// );
|
||||
}
|
||||
|
||||
if (!approvalGroup) {
|
||||
const isWaiting = currentStepNumber === latestApproval.step_number + 1;
|
||||
// Check if this step is waiting (only if we have latestApproval)
|
||||
const isWaiting =
|
||||
latestApproval?.step_number !== undefined &&
|
||||
currentStepNumber === latestApproval.step_number + 1;
|
||||
|
||||
// Check if previous approval was rejected
|
||||
const isPreviousApprovalRejected =
|
||||
groupedApprovals[groupedApprovals.length - 1].approvals[0].action ===
|
||||
'REJECTED';
|
||||
groupedApprovals &&
|
||||
groupedApprovals.length > 0 &&
|
||||
groupedApprovals[groupedApprovals.length - 1]?.approvals?.[0]
|
||||
?.action === 'REJECTED';
|
||||
|
||||
return {
|
||||
name: approvalLineItem.step_name,
|
||||
@@ -184,7 +196,11 @@ export const formatGroupedApprovalsToApprovalSteps = (
|
||||
|
||||
let approvalStatus: ApprovalStepStatus = 'IDLE';
|
||||
|
||||
if (approvalGroup.step_number <= latestApproval.step_number) {
|
||||
// Only compare if latestApproval and its step_number exist
|
||||
if (
|
||||
latestApproval?.step_number !== undefined &&
|
||||
approvalGroup.step_number <= latestApproval.step_number
|
||||
) {
|
||||
if (approvalGroup.approvals) {
|
||||
switch (approvalGroup?.approvals[0]?.action) {
|
||||
case 'CREATED':
|
||||
@@ -203,6 +219,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
latestApproval?.step_number !== undefined &&
|
||||
approvalGroup.step_number === latestApproval.step_number + 1 &&
|
||||
!isLatestApprovalRejected
|
||||
) {
|
||||
@@ -292,7 +309,7 @@ const useApprovalSteps = ({
|
||||
moduleId: string;
|
||||
params?: {
|
||||
page?: number;
|
||||
limit: number;
|
||||
limit: number | string;
|
||||
search?: string;
|
||||
group_step_number?: boolean;
|
||||
};
|
||||
@@ -353,14 +370,33 @@ const useApprovalSteps = ({
|
||||
|
||||
// Formatting Akhir
|
||||
const approvals = useMemo(() => {
|
||||
if (isLoading || !approvalLines.length || !latestApproval) {
|
||||
if (isLoading || !approvalLines.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try to derive latestApproval from groupedApprovals if not provided
|
||||
let effectiveLatestApproval = latestApproval;
|
||||
|
||||
if (!effectiveLatestApproval && groupedApprovals.length > 0) {
|
||||
// Get all approvals from grouped data
|
||||
const allApprovals = groupedApprovals.flatMap((group) => group.approvals);
|
||||
|
||||
if (allApprovals.length > 0) {
|
||||
// Use the most recent approval (last in array)
|
||||
effectiveLatestApproval = allApprovals[allApprovals.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// If still no latestApproval, return empty
|
||||
if (!effectiveLatestApproval) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return formatGroupedApprovalsToApprovalSteps(
|
||||
approvalLines,
|
||||
groupedApprovals,
|
||||
latestApproval
|
||||
effectiveLatestApproval
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Gagal memformat approval steps:', error);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
import Tabs from '@/components/Tabs';
|
||||
import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable';
|
||||
import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent';
|
||||
import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent';
|
||||
|
||||
import {
|
||||
ClosingGeneralInformation,
|
||||
BaseClosingSales,
|
||||
ClosingHppExpedition,
|
||||
} from '@/types/api/closing';
|
||||
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
|
||||
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
|
||||
import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent';
|
||||
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
|
||||
import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable';
|
||||
import ClosingKandangList from '@/components/pages/closing/ClosingKandangList';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||
interface ClosingDetailProps {
|
||||
id: number;
|
||||
initialValue?: ClosingGeneralInformation;
|
||||
salesData?: BaseClosingSales;
|
||||
hppExpeditionData?: ClosingHppExpedition;
|
||||
projectData?: ProjectFlock;
|
||||
kandangData?: ProjectFlockKandang;
|
||||
}
|
||||
|
||||
const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||
id,
|
||||
initialValue,
|
||||
salesData,
|
||||
hppExpeditionData,
|
||||
projectData,
|
||||
kandangData,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<string>('sapronak');
|
||||
|
||||
const closingDetailTabs = useMemo(() => {
|
||||
const validTabs = [
|
||||
{
|
||||
id: 'sapronak',
|
||||
label: 'Sapronak',
|
||||
content: <ClosingSapronakTabContent projectFlockId={id} />,
|
||||
},
|
||||
{
|
||||
id: 'perhitunganSapronak',
|
||||
label: 'Perhitungan Sapronak',
|
||||
content: (
|
||||
<ClosingSapronakCalculationTabContent
|
||||
closingGeneralInformation={initialValue}
|
||||
projectFlockId={id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'penjualan',
|
||||
label: 'Penjualan',
|
||||
content: <SalesReportTable initialValues={salesData} />,
|
||||
},
|
||||
{
|
||||
id: 'overhead',
|
||||
label: 'Overhead',
|
||||
content: <ClosingOverheadTabContent projectFlockId={id} />,
|
||||
},
|
||||
{
|
||||
id: 'hppEkspedisi',
|
||||
label: 'HPP Ekspedisi',
|
||||
content: <HppExpeditionReportTable initialValues={hppExpeditionData} />,
|
||||
},
|
||||
{
|
||||
id: 'dataProduksi',
|
||||
label: 'Data Produksi',
|
||||
content: <ClosingProductionDataTabContent projectFlockId={id} />,
|
||||
},
|
||||
{
|
||||
id: 'keuangan',
|
||||
label: 'Keuangan',
|
||||
content: <ClosingFinanceTabContent projectFlockId={id} />,
|
||||
},
|
||||
];
|
||||
|
||||
return validTabs;
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-7xl pb-16'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href={
|
||||
!kandangData ? '/closing' : `/closing/detail/?closingId=${id}`
|
||||
}
|
||||
variant='link'
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||
Kembali
|
||||
</Button>
|
||||
|
||||
<h1 className='text-2xl font-bold text-center'>Detail Closing</h1>
|
||||
</header>
|
||||
|
||||
<ClosingGeneralInformationTable
|
||||
initialValue={initialValue}
|
||||
projectData={projectData}
|
||||
kandangData={kandangData}
|
||||
/>
|
||||
|
||||
{!kandangData && (
|
||||
<ClosingKandangList
|
||||
initialValue={initialValue}
|
||||
projectData={projectData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
activeTabId={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabs={closingDetailTabs}
|
||||
variant='lifted'
|
||||
className={{
|
||||
wrapper: 'w-full mt-4',
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingDetail;
|
||||
@@ -0,0 +1,17 @@
|
||||
import ClosingFinanceTable from '@/components/pages/closing/ClosingFinanceTable';
|
||||
|
||||
const ClosingFinanceTabContent = ({
|
||||
projectFlockId,
|
||||
}: {
|
||||
projectFlockId: number;
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{projectFlockId && (
|
||||
<ClosingFinanceTable projectFlockId={projectFlockId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingFinanceTabContent;
|
||||
@@ -0,0 +1,595 @@
|
||||
import Card from '@/components/Card';
|
||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatCurrency, formatTitleCase } from '@/lib/helper';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import {
|
||||
DataSummarySubTotal,
|
||||
HppPurchaseData,
|
||||
ProfitLossDataAmount,
|
||||
} from '@/types/api/closing';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type HppTableRow =
|
||||
| (HppPurchaseData & {
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader?: boolean;
|
||||
})
|
||||
| {
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader: true;
|
||||
type?: never;
|
||||
budgeting?: never;
|
||||
realization?: never;
|
||||
}
|
||||
| {
|
||||
type: string;
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader: false;
|
||||
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
|
||||
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
|
||||
};
|
||||
|
||||
type ProfitLossTableRow =
|
||||
| (DataSummarySubTotal & {
|
||||
type: string;
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader?: boolean;
|
||||
})
|
||||
| {
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader: true;
|
||||
type?: never;
|
||||
rp_per_bird?: never;
|
||||
rp_per_kg?: never;
|
||||
amount?: never;
|
||||
};
|
||||
|
||||
const ClosingFinanceTable = ({
|
||||
projectFlockId,
|
||||
}: {
|
||||
projectFlockId: number;
|
||||
}) => {
|
||||
const { data: finance, isLoading } = useSWR(
|
||||
`/closing/finance/${projectFlockId}`,
|
||||
() => ClosingApi.getFinance(projectFlockId)
|
||||
);
|
||||
|
||||
const staticHppRows: Array<{
|
||||
group_name: string;
|
||||
type: string;
|
||||
group_index: number;
|
||||
}> = [
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian PAKAN',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian STARTER',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian DOC',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian PULLET',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian LAYER',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
type: 'Pengeluaran Overhead',
|
||||
group_index: 1,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
type: 'Beban Ekspedisi',
|
||||
group_index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const hppTableData: HppTableRow[] = [
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
group_index: 0,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
...staticHppRows
|
||||
.filter((row) => row.group_index === 0)
|
||||
.map((staticRow) => {
|
||||
const apiData = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp
|
||||
.find((g) => g.group_name === staticRow.group_name)
|
||||
?.data.find((d) => d.type === staticRow.type)
|
||||
: null;
|
||||
|
||||
return {
|
||||
group_name: staticRow.group_name,
|
||||
group_index: staticRow.group_index,
|
||||
type: staticRow.type,
|
||||
budgeting: apiData?.budgeting || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
realization: apiData?.realization || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
isGroupHeader: false as const,
|
||||
};
|
||||
}),
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
group_index: 1,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
...staticHppRows
|
||||
.filter((row) => row.group_index === 1)
|
||||
.map((staticRow) => {
|
||||
const apiData = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp
|
||||
.find((g) => g.group_name === staticRow.group_name)
|
||||
?.data.find((d) => d.type === staticRow.type)
|
||||
: null;
|
||||
|
||||
return {
|
||||
group_name: staticRow.group_name,
|
||||
group_index: staticRow.group_index,
|
||||
type: staticRow.type,
|
||||
budgeting: apiData?.budgeting || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
realization: apiData?.realization || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
isGroupHeader: false as const,
|
||||
};
|
||||
}),
|
||||
{
|
||||
group_name: 'HPP',
|
||||
group_index: 2,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
];
|
||||
|
||||
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
|
||||
? [
|
||||
// Pembelian group
|
||||
...finance.data.profit_loss.data.pembelian.map((item) => ({
|
||||
label: 'Pembelian',
|
||||
group_name: 'Pembelian',
|
||||
group_index: 1,
|
||||
type: item.type,
|
||||
rp_per_bird: item.rp_per_bird,
|
||||
rp_per_kg: item.rp_per_kg,
|
||||
amount: item.amount,
|
||||
isGroupHeader: false as const,
|
||||
})),
|
||||
{
|
||||
label: finance.data.profit_loss.data.summary.gross_profit.label,
|
||||
group_name: 'Penjualan',
|
||||
group_index: 0,
|
||||
isGroupHeader: true as const,
|
||||
type: finance.data.profit_loss.data.summary.gross_profit.label,
|
||||
rp_per_bird:
|
||||
finance.data.profit_loss.data.summary.gross_profit.rp_per_bird,
|
||||
rp_per_kg:
|
||||
finance.data.profit_loss.data.summary.gross_profit.rp_per_kg,
|
||||
amount: finance.data.profit_loss.data.summary.gross_profit.amount,
|
||||
},
|
||||
// Penjualan group
|
||||
...finance.data.profit_loss.data.penjualan.map((item) => ({
|
||||
label: 'Penjualan',
|
||||
group_name: 'Penjualan',
|
||||
group_index: 0,
|
||||
type: item.type,
|
||||
rp_per_bird: item.rp_per_bird,
|
||||
rp_per_kg: item.rp_per_kg,
|
||||
amount: item.amount,
|
||||
isGroupHeader: false as const,
|
||||
})),
|
||||
{
|
||||
label: finance.data.profit_loss.data.summary.sub_total.label,
|
||||
group_name: 'Pembelian',
|
||||
group_index: 1,
|
||||
isGroupHeader: true as const,
|
||||
type: finance.data.profit_loss.data.summary.sub_total.label,
|
||||
rp_per_bird:
|
||||
finance.data.profit_loss.data.summary.sub_total.rp_per_bird,
|
||||
rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg,
|
||||
amount: finance.data.profit_loss.data.summary.sub_total.amount,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<>
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-6'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div>
|
||||
{isResponseSuccess(finance)
|
||||
? formatTitleCase(
|
||||
finance.data.profit_loss.data.summary.gross_profit
|
||||
.label || '-'
|
||||
)
|
||||
: 'Laba Rugi Brutto'}
|
||||
</div>
|
||||
<div className='text-lg font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.gross_profit.amount
|
||||
)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div>
|
||||
{isResponseSuccess(finance)
|
||||
? formatTitleCase(
|
||||
finance.data.profit_loss.data.summary.net_profit.label ||
|
||||
'-'
|
||||
)
|
||||
: 'Laba Rugi Netto'}
|
||||
</div>
|
||||
<div className='text-lg font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit.amount
|
||||
)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.title
|
||||
: 'HPP Purchases'
|
||||
}
|
||||
variant='bordered'
|
||||
collapsible
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<div className='mt-6 p-0 mb-0'>
|
||||
<Table<HppTableRow>
|
||||
data={hppTableData}
|
||||
columns={[
|
||||
{
|
||||
header: 'No.',
|
||||
enableSorting: false,
|
||||
accessorFn: (item, index) => {
|
||||
if (item.isGroupHeader) return '-';
|
||||
const dataRowsBefore = hppTableData
|
||||
.slice(0, index)
|
||||
.filter((row) => !row.isGroupHeader).length;
|
||||
return dataRowsBefore + 1;
|
||||
},
|
||||
footer: (props) => {
|
||||
return 'HPP';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatTitleCase(item.type || '-'),
|
||||
},
|
||||
{
|
||||
header: 'Budgeting',
|
||||
enableSorting: false,
|
||||
columns: [
|
||||
{
|
||||
header: 'Rp/Ekor',
|
||||
id: 'budgeting_rp_per_bird',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) =>
|
||||
formatCurrency(item.budgeting?.rp_per_bird || 0),
|
||||
footer: (props) => {
|
||||
return props.column.id === 'budgeting_rp_per_bird' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.rp_per_bird || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Rp/Kg',
|
||||
id: 'budgeting_rp_per_kg',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) =>
|
||||
formatCurrency(item.budgeting?.rp_per_kg || 0),
|
||||
footer: (props) => {
|
||||
return props.column.id === 'budgeting_rp_per_kg' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.rp_per_kg || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Jumlah (Rp)',
|
||||
id: 'budgeting_amount',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) =>
|
||||
formatCurrency(item.budgeting?.amount || 0),
|
||||
footer: (props) => {
|
||||
return props.column.id === 'budgeting_amount' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.amount || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: 'Realization',
|
||||
enableSorting: false,
|
||||
columns: [
|
||||
{
|
||||
header: 'Rp/Ekor',
|
||||
id: 'realization_rp_per_bird',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) =>
|
||||
formatCurrency(item.realization?.rp_per_bird || 0),
|
||||
footer: (props) => {
|
||||
return props.column.id === 'realization_rp_per_bird' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.rp_per_bird || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Rp/Kg',
|
||||
id: 'realization_rp_per_kg',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) =>
|
||||
formatCurrency(item.realization?.rp_per_kg || 0),
|
||||
footer: (props) => {
|
||||
return props.column.id === 'realization_rp_per_kg' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.rp_per_kg || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Jumlah (Rp)',
|
||||
id: 'realization_amount',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) =>
|
||||
formatCurrency(item.realization?.amount || 0),
|
||||
footer: (props) => {
|
||||
return props.column.id === 'realization_amount' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.amount || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
renderCustomRow={(row) => {
|
||||
const rowData = row.original;
|
||||
if (rowData.isGroupHeader) {
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
|
||||
>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
></td>
|
||||
<td
|
||||
colSpan={7}
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatTitleCase(rowData.group_name ?? '-')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
renderFooter={isResponseSuccess(finance)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
isResponseSuccess(finance)
|
||||
? finance.data.profit_loss.title
|
||||
: 'Profit/Loss'
|
||||
}
|
||||
variant='bordered'
|
||||
collapsible
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<div className='mt-6 p-0 mb-0'>
|
||||
<Table<ProfitLossTableRow>
|
||||
data={profitLossTableData}
|
||||
columns={[
|
||||
{
|
||||
header: 'Jenis',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => item.type,
|
||||
cell: (item) => (
|
||||
<div className=''>
|
||||
{formatTitleCase(item.row.original.type || '-')}
|
||||
</div>
|
||||
),
|
||||
footer: (item) => (
|
||||
<div className='font-bold uppercase'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatTitleCase(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
.label || '-'
|
||||
)
|
||||
: '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Rp/Ekor',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
|
||||
footer: (item) => (
|
||||
<div className='font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
.rp_per_bird || 0
|
||||
)
|
||||
: formatCurrency(0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Rp/Kg',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
|
||||
footer: (item) => (
|
||||
<div className='font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
.rp_per_kg || 0
|
||||
)
|
||||
: formatCurrency(0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Jumlah (Rp)',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatCurrency(item.amount || 0),
|
||||
footer: (item) => (
|
||||
<div className='font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
.amount || 0
|
||||
)
|
||||
: formatCurrency(0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderCustomRow={(row) => {
|
||||
const rowData = row.original;
|
||||
if (rowData.isGroupHeader) {
|
||||
if (rowData.amount) {
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={TABLE_DEFAULT_STYLING.footerRowClassName}
|
||||
>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold ps-6 uppercase'>
|
||||
{formatTitleCase(rowData.label ?? '-')}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.rp_per_bird ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.rp_per_kg ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.amount ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
|
||||
>
|
||||
<td
|
||||
colSpan={4}
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatTitleCase(rowData.group_name ?? '-')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
className={{
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
renderFooter={isResponseSuccess(finance)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingFinanceTable;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user