mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 05:22:02 +00:00
Compare commits
514 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b13794ee5 | |||
| 8e88355894 | |||
| 1b98e5d4d8 | |||
| d8daf09844 | |||
| 0bb9aee139 | |||
| 306b8d3bf3 | |||
| 2bf764a05c | |||
| 6c3285f624 | |||
| afb79b0589 | |||
| 4f571f1c16 | |||
| 81ca60a09b | |||
| 23453eb8f5 | |||
| 3dc32da834 | |||
| f089492830 | |||
| 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 | |||
| 2712821f4e | |||
| 7e73c99074 | |||
| 0f86ba0f2d | |||
| 10a37fde75 | |||
| cd3a5ad441 | |||
| cd42bd6bc0 | |||
| ea7f8a68f4 | |||
| e0371b0884 | |||
| 432d837aaf | |||
| 77b05c6440 | |||
| 731bec5a94 | |||
| 6ea25aa3b1 | |||
| d4f4505405 | |||
| bd653851e2 | |||
| b9b349aa7a | |||
| 6ec6323bbc | |||
| c44e63bd2b | |||
| 500c30c2bc | |||
| 507c4005af | |||
| d49bca1d40 | |||
| 663c1dea14 | |||
| 9e0d3e2bbf | |||
| 4be719b9d8 | |||
| 1152cd2bef | |||
| 4ddd1dc8e3 | |||
| 8c95dc8327 | |||
| df6c1ae49d | |||
| 42a56a08d7 | |||
| 6ed7dcfa6d | |||
| dda29e10d1 | |||
| 9d9b9d93db | |||
| f41899dbc9 | |||
| 36ff6d04ee | |||
| de63b6721a | |||
| a200dac23c | |||
| fcfd2fb576 | |||
| 2c28d0a831 | |||
| 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
|
||||
|
||||
+29
-6
@@ -73,8 +73,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
|
||||
@@ -122,11 +122,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,7 +139,32 @@ deploy:dev:
|
||||
environment:
|
||||
name: development
|
||||
url: https://dev-lti-erp.mbugroup.id
|
||||
|
||||
|
||||
# ====== STAGING (Branch staging) ======
|
||||
build:staging:
|
||||
<<: *build_template
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
environment:
|
||||
name: staging
|
||||
variables:
|
||||
NEXT_PUBLIC_LTI_URL: 'https://stg-lti-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
|
||||
deploy:staging:
|
||||
<<: *deploy_template
|
||||
needs: ['build:staging']
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
variables:
|
||||
S3_BUCKET: 'stg-lti-erp.mbugroup.id'
|
||||
AWS_REGION: 'ap-southeast-3'
|
||||
CLOUDFRONT_DISTRIBUTION_ID: 'E2V6PPO1AUIU7H'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://stg-lti-erp.mbugroup.id
|
||||
# ====== PRODUCTION ======
|
||||
# build:production:
|
||||
# <<: *build_template
|
||||
@@ -163,4 +187,3 @@ deploy:dev:
|
||||
# environment:
|
||||
# name: production
|
||||
|
||||
|
||||
|
||||
+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
+642
-20
@@ -14,8 +14,10 @@
|
||||
"axios": "^1.12.2",
|
||||
"clsx": "^2.1.1",
|
||||
"formik": "^2.4.6",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"next": "15.5.7",
|
||||
"next": "15.5.9",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -23,9 +25,11 @@
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-select": "^5.10.2",
|
||||
"recharts": "^3.6.0",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-debounce": "^10.0.6",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@@ -36,9 +40,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",
|
||||
@@ -1082,15 +1086,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
|
||||
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "15.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz",
|
||||
"integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.7.tgz",
|
||||
"integrity": "sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1447,6 +1451,42 @@
|
||||
"@react-pdf/stylesheet": "^6.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1461,6 +1501,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1801,6 +1853,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1844,12 +1959,25 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
@@ -1878,6 +2006,19 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
|
||||
@@ -2775,6 +2916,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -2924,6 +3075,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -3019,6 +3190,18 @@
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
|
||||
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
@@ -3056,16 +3239,147 @@
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.3.10",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.10.tgz",
|
||||
"integrity": "sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==",
|
||||
"version": "5.5.8",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.8.tgz",
|
||||
"integrity": "sha512-6psL9jIEOFOw68V10j/BKCWcRgx8dh81mmNxShr+g7HDM6UHNoPharlp9zq/PQkHNuGU1ZQsajR3HgpvavbRKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3166,6 +3480,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3275,6 +3595,16 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -3498,6 +3828,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -3571,13 +3911,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "15.5.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz",
|
||||
"integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==",
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.7.tgz",
|
||||
"integrity": "sha512-nU/TRGHHeG81NeLW5DeQT5t6BDUqbpsNQTvef1ld/tqHT+/zTx60/TIhKnmPISTTe++DVo+DLxDmk4rnwHaZVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "15.5.3",
|
||||
"@next/eslint-plugin-next": "15.5.7",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
@@ -3935,6 +4275,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
@@ -3994,6 +4340,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png/node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
@@ -4004,6 +4367,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -4491,6 +4860,20 @@
|
||||
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
@@ -4523,6 +4906,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4570,6 +4963,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -5105,6 +5513,32 @@
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
|
||||
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf-autotable": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz",
|
||||
"integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"jspdf": "^2 || ^3"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -5654,12 +6088,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
|
||||
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.7",
|
||||
"@next/env": "15.5.9",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -6009,6 +6443,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -6162,6 +6603,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
@@ -6260,6 +6711,29 @@
|
||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||
@@ -6297,6 +6771,51 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
||||
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6320,6 +6839,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@@ -6356,6 +6882,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6412,6 +6944,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -6761,6 +7303,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -6980,6 +7532,16 @@
|
||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
|
||||
@@ -7024,6 +7586,16 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-case": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||
@@ -7036,6 +7608,12 @@
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
@@ -7396,6 +7974,38 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-compatible-readable-stream": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||
@@ -7525,6 +8135,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
|
||||
+7
-3
@@ -17,8 +17,10 @@
|
||||
"axios": "^1.12.2",
|
||||
"clsx": "^2.1.1",
|
||||
"formik": "^2.4.6",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"next": "15.5.7",
|
||||
"next": "15.5.9",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -26,9 +28,11 @@
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-select": "^5.10.2",
|
||||
"recharts": "^3.6.0",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"use-debounce": "^10.0.6",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yup": "^1.7.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@@ -39,9 +43,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,69 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ClosingDetail from '@/components/pages/closing/ClosingDetail';
|
||||
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const ClosingDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const closingId = searchParams.get('closingId');
|
||||
|
||||
const { data: closing, isLoading: isLoadingClosing } = useSWR(
|
||||
closingId,
|
||||
(id: number) => ClosingApi.getGeneralInfo(id)
|
||||
);
|
||||
|
||||
const { data: salesData, isLoading: isLoadingSales } = useSWR(
|
||||
closingId ? `sales-${closingId}` : null,
|
||||
() => ClosingApi.getPenjualan(Number(closingId))
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
+39
-20
@@ -7,26 +7,39 @@
|
||||
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 +56,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;
|
||||
@@ -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,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,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;
|
||||
@@ -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
|
||||
|
||||
+103
-8
@@ -10,28 +10,102 @@ interface DrawerProps {
|
||||
open: boolean;
|
||||
setOpen: (newOpenState: boolean) => void;
|
||||
openOnLarge?: boolean;
|
||||
variant?: 'sidebar' | 'left' | 'right';
|
||||
zIndex?: string;
|
||||
className?: DrawerClassName;
|
||||
onBackdropClick?: () => void;
|
||||
closeOnBackdropClick?: boolean;
|
||||
}
|
||||
|
||||
type DrawerClassName = {
|
||||
drawer?: string;
|
||||
drawerContent?: string;
|
||||
drawerSide?: string;
|
||||
drawerOverlay?: string;
|
||||
drawerSidebarContent?: string;
|
||||
};
|
||||
|
||||
const Drawer = ({
|
||||
children,
|
||||
sidebarContent,
|
||||
open,
|
||||
setOpen,
|
||||
openOnLarge,
|
||||
variant = 'sidebar',
|
||||
zIndex = '20',
|
||||
className,
|
||||
onBackdropClick,
|
||||
closeOnBackdropClick = true,
|
||||
}: DrawerProps) => {
|
||||
const getDrawerClassNames = (): DrawerClassName => {
|
||||
const baseClassNames = {
|
||||
drawer: 'drawer',
|
||||
drawerContent: 'drawer-content',
|
||||
drawerSide: 'drawer-side',
|
||||
drawerOverlay: 'drawer-overlay',
|
||||
drawerSidebarContent: 'min-h-full bg-base-100',
|
||||
};
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return {
|
||||
...baseClassNames,
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
'w-full max-w-[300px] lg:w-[300px]'
|
||||
),
|
||||
};
|
||||
} else if (variant === 'right') {
|
||||
return {
|
||||
...baseClassNames,
|
||||
drawer: cn(baseClassNames.drawer, 'drawer-end'),
|
||||
drawerSide: cn(
|
||||
baseClassNames.drawerSide,
|
||||
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
|
||||
),
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
'w-full sm:min-w-120 sm:w-fit'
|
||||
),
|
||||
};
|
||||
} else if (variant === 'left') {
|
||||
return {
|
||||
...baseClassNames,
|
||||
drawerSide: cn(
|
||||
baseClassNames.drawerSide,
|
||||
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
|
||||
),
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
'w-full sm:min-w-120 sm:w-fit'
|
||||
),
|
||||
};
|
||||
}
|
||||
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,16 +114,37 @@ 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'>
|
||||
{/* Sidebar Content */}
|
||||
<div
|
||||
className={cn(
|
||||
varianClassName?.drawerSidebarContent,
|
||||
className?.drawerContent
|
||||
)}
|
||||
>
|
||||
{sidebarContent}
|
||||
</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-[10%] opacity-100'
|
||||
: 'bottom-[-10%] 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(
|
||||
`absolute ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`,
|
||||
'mx-auto w-full max-w-sm sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
|
||||
'bg-slate-950 backdrop-blur-md'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{/* === BARIS ATAS: Status Seleksi dan Actions (Termasuk Close) === */}
|
||||
<div className='flex justify-between items-center text-white'>
|
||||
<h4 className='text-base font-semibold'>
|
||||
{selectedRowIds.length} Selected
|
||||
</h4>
|
||||
|
||||
<div className='flex flex-row gap-1 items-stretch'>
|
||||
<div className='flex gap-4 items-center'>
|
||||
{/* Render Aksi dari props.actions */}
|
||||
{actions
|
||||
.filter((action) => {
|
||||
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;
|
||||
+19
-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,11 @@ const MainDrawer = ({
|
||||
}>) => {
|
||||
const { mainDrawerOpen, setMainDrawerOpen } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
const { permissionCheck } = useAuth();
|
||||
|
||||
const isPermitted = ROUTE_PERMISSIONS[pathname]?.some((permission) =>
|
||||
permissionCheck(permission)
|
||||
);
|
||||
|
||||
const getPageTitle = useCallback(() => {
|
||||
let title = '';
|
||||
@@ -216,9 +84,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 +109,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,64 @@
|
||||
'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 { 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 13 minutes
|
||||
refreshInterval: 13 * 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]);
|
||||
|
||||
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 +66,25 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <>{isResponseSuccess(userResponse) && children}</>;
|
||||
if (userErrorResponse) {
|
||||
return (
|
||||
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
|
||||
<h2 className='text-2xl font-bold text-error'>Authentication Failed</h2>
|
||||
<p className='text-gray-600'>
|
||||
Please try refreshing the page or contact support if the problem
|
||||
persists.
|
||||
</p>
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{isResponseSuccess(userResponse) && user && children}</>;
|
||||
};
|
||||
|
||||
export default RequireAuth;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
@@ -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,112 @@
|
||||
'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';
|
||||
|
||||
interface ClosingDetailProps {
|
||||
id: number;
|
||||
initialValue?: ClosingGeneralInformation;
|
||||
salesData?: BaseClosingSales;
|
||||
hppExpeditionData?: ClosingHppExpedition;
|
||||
}
|
||||
|
||||
const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||
id,
|
||||
initialValue,
|
||||
salesData,
|
||||
hppExpeditionData,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<string>('sapronak');
|
||||
|
||||
const closingDetailTabs = useMemo(() => {
|
||||
const validTabs = [
|
||||
{
|
||||
id: 'sapronak',
|
||||
label: 'Sapronak',
|
||||
content: <ClosingSapronakTabContent projectFlockId={id} />,
|
||||
},
|
||||
{
|
||||
id: 'perhitunganSapronak',
|
||||
label: 'Perhitungan Sapronak',
|
||||
content: <ClosingSapronakCalculationTabContent projectFlockId={id} />,
|
||||
},
|
||||
{
|
||||
id: 'penjualan',
|
||||
label: 'Penjualan',
|
||||
content: <SalesReportTable initialValues={salesData} />,
|
||||
},
|
||||
{
|
||||
id: 'overhead',
|
||||
label: 'Overhead',
|
||||
content: <ClosingOverheadTabContent projectFlockId={id} />,
|
||||
},
|
||||
{
|
||||
id: 'hppEkspedisi',
|
||||
label: 'HPP Ekspedisi',
|
||||
content: <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='/closing'
|
||||
variant='link'
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||
Kembali
|
||||
</Button>
|
||||
|
||||
<h1 className='text-2xl font-bold text-center'>Detail Closing</h1>
|
||||
</header>
|
||||
|
||||
<ClosingGeneralInformationTable initialValue={initialValue} />
|
||||
|
||||
<Tabs
|
||||
activeTabId={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabs={closingDetailTabs}
|
||||
variant='lifted'
|
||||
className={{
|
||||
wrapper: 'w-full mt-4',
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingDetail;
|
||||
@@ -0,0 +1,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,495 @@
|
||||
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 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 hppTableData: HppTableRow[] = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [
|
||||
// Group header row
|
||||
{
|
||||
group_name: hpp.group_name,
|
||||
group_index: groupIndex,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
// Data rows
|
||||
...hpp.data.map((item) => ({
|
||||
group_name: hpp.group_name,
|
||||
group_index: groupIndex,
|
||||
type: item.type,
|
||||
budgeting: item.budgeting,
|
||||
realization: item.realization,
|
||||
isGroupHeader: false 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;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ClosingGeneralInformation } from '@/types/api/closing';
|
||||
|
||||
interface ClosingGeneralInformationProps {
|
||||
initialValue?: ClosingGeneralInformation;
|
||||
}
|
||||
|
||||
const ClosingGeneralInformationTable = ({
|
||||
initialValue,
|
||||
}: ClosingGeneralInformationProps) => {
|
||||
return (
|
||||
<div className='w-full my-4 @container'>
|
||||
<div className='flex flex-col @sm:flex-row gap-4'>
|
||||
<div className='w-full'>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='table table-zebra table-sm'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Lokasi</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.location_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Periode</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.period}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Project Flock</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.project_flock?.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Populasi</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.population} Ekor</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Jenis Project</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.project_type}</td>
|
||||
</tr>
|
||||
<tr className='table-row @sm:hidden'>
|
||||
<td>Kandang Aktif</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.active_house_count} Kandang</td>
|
||||
</tr>
|
||||
<tr className='table-row @sm:hidden'>
|
||||
<td>Status Pembayaran Penjualan</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.sales_payment_status}</td>
|
||||
</tr>
|
||||
<tr className='table-row @sm:hidden'>
|
||||
<td>Status Project</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.project_status}</td>
|
||||
</tr>
|
||||
<tr className='table-row @sm:hidden'>
|
||||
<td>Status Closing</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.closing_status}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full hidden @sm:block'>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='table table-zebra table-sm'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Kandang Aktif</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.active_house_count} Kandang</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status Pembayaran Penjualan</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.sales_payment_status}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status Project</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.project_status}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status Closing</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.closing_status}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingGeneralInformationTable;
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import Card from '@/components/Card';
|
||||
import Collapse from '@/components/Collapse';
|
||||
|
||||
import { cn, formatDate, formatNumber } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { ClosingIncomingSapronak } from '@/types/api/closing';
|
||||
|
||||
interface ClosingIncomingSapronaksTableProps {
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingIncomingSapronaksTable = ({
|
||||
projectFlockId,
|
||||
}: ClosingIncomingSapronaksTableProps) => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
nameSort: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
},
|
||||
});
|
||||
|
||||
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
|
||||
useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`,
|
||||
ClosingApi.getAllIncomingSapronakFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
|
||||
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronak>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) => props.row.index + 1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Tanggal',
|
||||
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'reference_number',
|
||||
header: 'No. Referensi',
|
||||
},
|
||||
{
|
||||
accessorKey: 'transaction_type',
|
||||
header: 'Jenis Transaksi',
|
||||
},
|
||||
{
|
||||
accessorKey: 'product_name',
|
||||
header: 'Produk',
|
||||
},
|
||||
{
|
||||
accessorKey: 'product_category',
|
||||
header: 'Kategori Produk',
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_warehouse',
|
||||
header: 'Gudang Asal',
|
||||
},
|
||||
{
|
||||
accessorKey: 'destination_warehouse',
|
||||
header: 'Gudang Tujuan',
|
||||
},
|
||||
{
|
||||
accessorKey: 'quantity',
|
||||
header: 'Kuantitas',
|
||||
cell: (props) =>
|
||||
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'notes',
|
||||
header: 'Keterangan',
|
||||
},
|
||||
];
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
|
||||
if (!isNameSorted) {
|
||||
updateFilter('nameSort', '');
|
||||
} else {
|
||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||
}
|
||||
}, [sorting, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setOpen(
|
||||
isResponseSuccess(incomingSapronaks)
|
||||
? incomingSapronaks.data.length > 0
|
||||
: false
|
||||
);
|
||||
}
|
||||
}, [incomingSapronaks, isResponseSuccess]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={
|
||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
||||
<div className='card-title'>Sapronak Masuk</div>
|
||||
|
||||
<Icon
|
||||
icon='material-symbols:keyboard-arrow-down'
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn('text-primary transition-transform', {
|
||||
'-rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
className='w-full!'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<div className='w-full p-0'>
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Sapronak Masuk'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<ClosingIncomingSapronak>
|
||||
data={
|
||||
isResponseSuccess(incomingSapronaks)
|
||||
? incomingSapronaks?.data
|
||||
: []
|
||||
}
|
||||
columns={incomingSapronaksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
page={
|
||||
isResponseSuccess(incomingSapronaks)
|
||||
? incomingSapronaks?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(incomingSapronaks)
|
||||
? incomingSapronaks?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingIncomingSapronaks}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(incomingSapronaks) &&
|
||||
incomingSapronaks?.data?.length === 0,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingIncomingSapronaksTable;
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import Card from '@/components/Card';
|
||||
import Collapse from '@/components/Collapse';
|
||||
|
||||
import { cn, formatDate, formatNumber } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { ClosingOutgoingSapronak } from '@/types/api/closing';
|
||||
|
||||
interface ClosingOutgoingSapronaksTableProps {
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingOutgoingSapronaksTable = ({
|
||||
projectFlockId,
|
||||
}: ClosingOutgoingSapronaksTableProps) => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
nameSort: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
},
|
||||
});
|
||||
|
||||
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
|
||||
useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`,
|
||||
ClosingApi.getAllOutgoingSapronakFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
|
||||
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronak>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) => props.row.index + 1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Tanggal',
|
||||
cell: (props) => formatDate(props.row.original.date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'reference_number',
|
||||
header: 'No. Referensi',
|
||||
},
|
||||
{
|
||||
accessorKey: 'transaction_type',
|
||||
header: 'Jenis Transaksi',
|
||||
},
|
||||
{
|
||||
accessorKey: 'product_name',
|
||||
header: 'Produk',
|
||||
},
|
||||
{
|
||||
accessorKey: 'product_category',
|
||||
header: 'Kategori Produk',
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_warehouse',
|
||||
header: 'Gudang Asal',
|
||||
},
|
||||
{
|
||||
accessorKey: 'destination_warehouse',
|
||||
header: 'Gudang Tujuan',
|
||||
},
|
||||
{
|
||||
accessorKey: 'quantity',
|
||||
header: 'Kuantitas',
|
||||
cell: (props) =>
|
||||
`${formatNumber(props.row.original.quantity)} ${props.row.original.unit}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'notes',
|
||||
header: 'Keterangan',
|
||||
},
|
||||
];
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
|
||||
if (!isNameSorted) {
|
||||
updateFilter('nameSort', '');
|
||||
} else {
|
||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||
}
|
||||
}, [sorting, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setOpen(
|
||||
isResponseSuccess(outgoingSapronaks)
|
||||
? outgoingSapronaks.data.length > 0
|
||||
: false
|
||||
);
|
||||
}
|
||||
}, [outgoingSapronaks, isResponseSuccess]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={
|
||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
||||
<div className='card-title'>Sapronak Keluar</div>
|
||||
|
||||
<Icon
|
||||
icon='material-symbols:keyboard-arrow-down'
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn('text-primary transition-transform', {
|
||||
'-rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
className='w-full!'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<div className='w-full p-0'>
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-start items-end sm:items-center gap-4'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Sapronak Keluar'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<ClosingOutgoingSapronak>
|
||||
data={
|
||||
isResponseSuccess(outgoingSapronaks)
|
||||
? outgoingSapronaks?.data
|
||||
: []
|
||||
}
|
||||
columns={outgoingSapronaksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
page={
|
||||
isResponseSuccess(outgoingSapronaks)
|
||||
? outgoingSapronaks?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(outgoingSapronaks)
|
||||
? outgoingSapronaks?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingOutgoingSapronaks}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(outgoingSapronaks) &&
|
||||
outgoingSapronaks?.data?.length === 0,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingOutgoingSapronaksTable;
|
||||
@@ -0,0 +1,19 @@
|
||||
import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable';
|
||||
|
||||
interface ClosingOverheadTabContentProps {
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingOverheadTabContent = ({
|
||||
projectFlockId,
|
||||
}: ClosingOverheadTabContentProps) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{projectFlockId && (
|
||||
<ClosingOverheadTable projectFlockId={projectFlockId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingOverheadTabContent;
|
||||
@@ -0,0 +1,162 @@
|
||||
import Card from '@/components/Card';
|
||||
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { Overhead, OverheadTotal } from '@/types/api/closing';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface ClosingOverheadTableProps {
|
||||
type?: 'detail';
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingOverheadTable = ({
|
||||
type,
|
||||
projectFlockId,
|
||||
}: ClosingOverheadTableProps) => {
|
||||
const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/overhead`,
|
||||
() => ClosingApi.getOverhead(projectFlockId),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function to create columns with footer support
|
||||
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [
|
||||
// Group untuk kolom tanpa footer
|
||||
{
|
||||
header: 'Nama Item',
|
||||
accessorFn: (props) => props.item_name,
|
||||
footer: 'Total Pengeluaran Overhead',
|
||||
},
|
||||
{
|
||||
header: 'Satuan',
|
||||
accessorFn: (props) => props.uom_name,
|
||||
},
|
||||
{
|
||||
header: 'Budget Pengajuan',
|
||||
footer: '',
|
||||
columns: [
|
||||
{
|
||||
id: 'budget_quantity',
|
||||
header: 'Jumlah',
|
||||
accessorFn: (props) =>
|
||||
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
|
||||
footer: total ? () => formatNumber(total.budget_quantity) : '',
|
||||
},
|
||||
{
|
||||
id: 'budget_unit_price',
|
||||
header: 'Harga Satuan',
|
||||
accessorFn: (props) =>
|
||||
props.budget_unit_price
|
||||
? formatCurrency(props.budget_unit_price)
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'budget_total_amount',
|
||||
header: 'Total',
|
||||
accessorFn: (props) =>
|
||||
props.budget_total_amount
|
||||
? formatCurrency(props.budget_total_amount)
|
||||
: '-',
|
||||
footer: total ? () => formatCurrency(total.budget_total_amount) : '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: 'Realisasi',
|
||||
footer: '',
|
||||
columns: [
|
||||
{
|
||||
id: 'actual_date',
|
||||
header: 'Tanggal',
|
||||
accessorFn: (props) =>
|
||||
props.actual_date
|
||||
? formatDate(props.actual_date, 'DD MMM, YYYY')
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_quantity',
|
||||
header: 'Jumlah',
|
||||
accessorFn: (props) =>
|
||||
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
|
||||
footer: total ? () => formatNumber(total.actual_quantity) : '',
|
||||
},
|
||||
{
|
||||
id: 'actual_unit_price',
|
||||
header: 'Harga Satuan',
|
||||
accessorFn: (props) =>
|
||||
props.actual_unit_price
|
||||
? formatCurrency(props.actual_unit_price)
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_total_amount',
|
||||
header: 'Total',
|
||||
accessorFn: (props) =>
|
||||
props.actual_total_amount
|
||||
? formatCurrency(props.actual_total_amount)
|
||||
: '-',
|
||||
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cost_per_bird',
|
||||
header: 'Rp/Ekor',
|
||||
accessorFn: (props) =>
|
||||
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
|
||||
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
|
||||
},
|
||||
];
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(overhead)
|
||||
? createColumns(overhead.data?.total)
|
||||
: createColumns(),
|
||||
[overhead]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title='Pengeluaran Overhead'
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Table<Overhead>
|
||||
data={
|
||||
isResponseSuccess(overhead) ? (overhead.data?.overheads ?? []) : []
|
||||
}
|
||||
columns={columns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
headerColumnClassName: cn(
|
||||
TABLE_DEFAULT_STYLING.headerColumnClassName,
|
||||
'whitespace-nowrap'
|
||||
),
|
||||
}}
|
||||
renderFooter={
|
||||
isResponseSuccess(overhead)
|
||||
? overhead.data?.overheads.length > 0
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingOverheadTable;
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
|
||||
interface ClosingProductionDataTabContentProps {
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingProductionDataTabContent = ({
|
||||
projectFlockId,
|
||||
}: ClosingProductionDataTabContentProps) => {
|
||||
const { data: productionData, isLoading } = useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/production-data`,
|
||||
() => ClosingApi.getProductionData(projectFlockId)
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='w-full flex justify-center py-8'>
|
||||
<span className='loading loading-spinner loading-lg' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!productionData || !isResponseSuccess(productionData)) {
|
||||
return (
|
||||
<div className='w-full text-center py-8 text-gray-500'>
|
||||
Gagal memuat data produksi.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { purchase, sales, performance } = productionData.data;
|
||||
|
||||
// Helper for consistent row styling
|
||||
const DataRow = ({
|
||||
label,
|
||||
value,
|
||||
unit = '',
|
||||
valueClassName = 'font-bold text-gray-800',
|
||||
unitClassName = 'text-gray-500 w-12 text-right',
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
valueClassName?: string;
|
||||
unitClassName?: string;
|
||||
}) => (
|
||||
<div className='flex justify-between items-center py-1'>
|
||||
<span className='text-gray-500 text-sm font-medium w-1/2'>{label}</span>
|
||||
<div className='flex gap-2 w-1/2 justify-end items-center'>
|
||||
<span className={valueClassName}>{value}</span>
|
||||
{unit && <span className={unitClassName}>{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='w-full rounded-xl p-8 shadow-sm'>
|
||||
<h2 className='text-lg font-bold mb-8 text-gray-800'>Data Produksi</h2>
|
||||
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-x-24 gap-y-12 relative'>
|
||||
{/* Left Column */}
|
||||
<div className='space-y-10'>
|
||||
{/* Purchase Section */}
|
||||
<section>
|
||||
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
||||
Pembelian
|
||||
</h3>
|
||||
<div className='space-y-1'>
|
||||
<DataRow
|
||||
label='Populasi Awal'
|
||||
value={formatNumber(purchase.initial_population)}
|
||||
unit='Ekor'
|
||||
/>
|
||||
<DataRow
|
||||
label='Claim Culling'
|
||||
value={formatNumber(purchase.claim_culling)}
|
||||
unit='Ekor'
|
||||
/>
|
||||
<DataRow
|
||||
label='Populasi Akhir'
|
||||
value={formatNumber(purchase.final_population)}
|
||||
unit='Ekor'
|
||||
/>
|
||||
<DataRow
|
||||
label='Pakan Masuk'
|
||||
value={formatNumber(purchase.feed_in)}
|
||||
unit='Kg'
|
||||
/>
|
||||
<DataRow
|
||||
label='Pakan Terpakai'
|
||||
value={formatNumber(purchase.feed_used)}
|
||||
unit='Kg'
|
||||
/>
|
||||
<DataRow
|
||||
label='Pakan Terpakai per Ekor'
|
||||
value={formatNumber(purchase.feed_used_per_head)}
|
||||
unit='Kg'
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sales Section */}
|
||||
<section>
|
||||
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
||||
Penjualan
|
||||
</h3>
|
||||
<div className='space-y-4'>
|
||||
{/* Chicken Sales */}
|
||||
<div className='space-y-1'>
|
||||
<DataRow
|
||||
label='Penjualan (Ekor)'
|
||||
value={formatNumber(sales.chicken.sales_population)}
|
||||
unit='Ekor'
|
||||
/>
|
||||
<DataRow
|
||||
label='Penjualan (Kg)'
|
||||
value={formatNumber(sales.chicken.sales_weight)}
|
||||
unit='Kg'
|
||||
/>
|
||||
<DataRow
|
||||
label='Bobot Rata-Rata'
|
||||
value={formatNumber(sales.chicken.average_weight)}
|
||||
unit='Kg/Ekor'
|
||||
/>
|
||||
<DataRow
|
||||
label='Harga Jual Rata-Rata'
|
||||
value={formatNumber(
|
||||
sales.chicken.chicken_average_selling_price
|
||||
)}
|
||||
unit='Rupiah'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Egg Sales (if available) */}
|
||||
{sales.egg && (
|
||||
<>
|
||||
<div className='h-px bg-gray-100 my-2' />
|
||||
<div className='space-y-1'>
|
||||
<DataRow
|
||||
label='Telur (Butir)'
|
||||
value={formatNumber(sales.egg.egg_pieces)}
|
||||
unit='Butir'
|
||||
/>
|
||||
<DataRow
|
||||
label='Telur (Kg)'
|
||||
value={formatNumber(sales.egg.egg_mass_kg)}
|
||||
unit='Kg'
|
||||
/>
|
||||
<DataRow
|
||||
label='Berat Telur Rata-Rata'
|
||||
value={formatNumber(sales.egg.average_egg_weight_kg)}
|
||||
unit='Kg'
|
||||
/>
|
||||
<DataRow
|
||||
label='Harga Jual Telur Rata-Rata'
|
||||
value={formatNumber(sales.egg.egg_average_selling_price)}
|
||||
unit='Rupiah'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Divider Line (Absolute centered) */}
|
||||
<div className='hidden lg:block absolute left-1/2 top-0 bottom-0 w-px bg-gray-200 -translate-x-1/2' />
|
||||
|
||||
{/* Right Column */}
|
||||
<div className='space-y-10 flex flex-col h-full'>
|
||||
{/* Performance Section */}
|
||||
<section>
|
||||
<h3 className='font-bold text-gray-700 mb-4 text-base'>
|
||||
Performance
|
||||
</h3>
|
||||
<div className='space-y-1'>
|
||||
<DataRow
|
||||
label='Deplesi'
|
||||
value={formatNumber(performance.depletion)}
|
||||
unit='Ekor'
|
||||
/>
|
||||
<DataRow
|
||||
label='Umur'
|
||||
value={formatNumber(performance.age_day)}
|
||||
unit='Hari'
|
||||
/>
|
||||
<DataRow
|
||||
label='Mortalitas Std'
|
||||
value={formatNumber(performance.mortality_std)}
|
||||
unitClassName='hidden'
|
||||
/>
|
||||
<DataRow
|
||||
label='Mortalitas Act'
|
||||
value={formatNumber(performance.mortality_act)}
|
||||
unitClassName='hidden'
|
||||
/>
|
||||
<DataRow
|
||||
label='DEFF Mortalitas'
|
||||
value={formatNumber(performance.deff_mortality)}
|
||||
unitClassName='hidden'
|
||||
/>
|
||||
<DataRow
|
||||
label='FCR Std'
|
||||
value={formatNumber(performance.fcr_std)}
|
||||
unitClassName='hidden'
|
||||
/>
|
||||
<DataRow
|
||||
label='FCR Act'
|
||||
value={formatNumber(performance.fcr_act)}
|
||||
unitClassName='hidden'
|
||||
/>
|
||||
<DataRow
|
||||
label='DEFF FCR'
|
||||
value={formatNumber(performance.deff_fcr)}
|
||||
unitClassName='hidden'
|
||||
/>
|
||||
<DataRow
|
||||
label='AWG'
|
||||
value={formatNumber(performance.awg)}
|
||||
unit='Gr/Hari'
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingProductionDataTabContent;
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
|
||||
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
|
||||
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
|
||||
|
||||
interface ClosingSapronakCalculationTabContentProps {
|
||||
projectFlockId?: number;
|
||||
}
|
||||
|
||||
const ClosingSapronakCalculationTabContent = ({
|
||||
projectFlockId,
|
||||
}: ClosingSapronakCalculationTabContentProps) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{projectFlockId && (
|
||||
<>
|
||||
<ClosingSapronakCalculationTable projectFlockId={projectFlockId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingSapronakCalculationTabContent;
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/Card';
|
||||
|
||||
import Table from '@/components/Table';
|
||||
import { formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import {
|
||||
RowSapronakCalculation,
|
||||
TotalSapronakCalculation,
|
||||
} from '@/types/api/closing';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
interface ClosingSapronakCalculationTableProps {
|
||||
type?: 'detail';
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingSapronakCalculationTable = ({
|
||||
type,
|
||||
projectFlockId,
|
||||
}: ClosingSapronakCalculationTableProps) => {
|
||||
const { data: sapronakCalculation, isLoading } = useSWR(
|
||||
`/closing/sapronak-calculation/${projectFlockId}`,
|
||||
() => ClosingApi.getPerhitunganSapronak(projectFlockId),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function to create columns with footer support
|
||||
const createColumns = (
|
||||
total?: TotalSapronakCalculation
|
||||
): ColumnDef<RowSapronakCalculation>[] => [
|
||||
{
|
||||
header: 'Tanggal',
|
||||
accessorKey: 'tanggal',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
footer: 'Total',
|
||||
},
|
||||
{
|
||||
header: 'No. Referensi',
|
||||
accessorKey: 'no_referensi',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
header: 'QTY Masuk',
|
||||
accessorKey: 'qty_masuk',
|
||||
cell: (props) => formatNumber(props.getValue() as number),
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatNumber(total?.qty_masuk)}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'QTY Keluar',
|
||||
accessorKey: 'qty_keluar',
|
||||
cell: (props) => formatNumber(props.getValue() as number),
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatNumber(total?.qty_keluar)}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'QTY Pakai',
|
||||
accessorKey: 'qty_pakai',
|
||||
cell: (props) => formatNumber(props.getValue() as number),
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatNumber(total?.qty_pakai)}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'Uraian',
|
||||
accessorKey: 'uraian',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
header: 'Kategori Produk',
|
||||
accessorKey: 'kategori_produk',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
header: 'Harga Beli/Qty (Rp)',
|
||||
accessorKey: 'harga_beli_per_qty',
|
||||
cell: (props) => formatCurrency(props.getValue() as number),
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatCurrency(total?.harga_beli_per_qty)}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'Total Harga (Rp)',
|
||||
accessorKey: 'total_harga',
|
||||
cell: (props) => formatCurrency(props.getValue() as number),
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatCurrency(total?.total_harga)}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'Keterangan',
|
||||
accessorKey: 'keterangan',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Memoize columns untuk setiap kategori
|
||||
const docBroilerColumns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? createColumns(sapronakCalculation.data?.doc_broiler?.total)
|
||||
: createColumns(),
|
||||
[sapronakCalculation]
|
||||
);
|
||||
|
||||
const ovkColumns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? createColumns(sapronakCalculation.data?.ovk?.total)
|
||||
: createColumns(),
|
||||
[sapronakCalculation]
|
||||
);
|
||||
|
||||
const pakanColumns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? createColumns(sapronakCalculation.data?.pakan?.total)
|
||||
: createColumns(),
|
||||
[sapronakCalculation]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Card
|
||||
title='DOC Broiler'
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Table<RowSapronakCalculation>
|
||||
data={
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? (sapronakCalculation.data?.doc_broiler?.rows ?? [])
|
||||
: []
|
||||
}
|
||||
columns={docBroilerColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter={isResponseSuccess(sapronakCalculation)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title='OVK'
|
||||
variant='bordered'
|
||||
collapsible
|
||||
defaultCollapsed={true}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<Table<RowSapronakCalculation>
|
||||
data={
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? (sapronakCalculation.data?.ovk?.rows ?? [])
|
||||
: []
|
||||
}
|
||||
columns={ovkColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter={isResponseSuccess(sapronakCalculation)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title='Pakan'
|
||||
variant='bordered'
|
||||
collapsible
|
||||
defaultCollapsed={true}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<Table<RowSapronakCalculation>
|
||||
data={
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? (sapronakCalculation.data?.pakan?.rows ?? [])
|
||||
: []
|
||||
}
|
||||
columns={pakanColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter={isResponseSuccess(sapronakCalculation)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingSapronakCalculationTable;
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
|
||||
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
|
||||
|
||||
interface ClosingSapronakTableProps {
|
||||
projectFlockId?: number;
|
||||
}
|
||||
|
||||
const ClosingSapronakTabContent = ({
|
||||
projectFlockId,
|
||||
}: ClosingSapronakTableProps) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{projectFlockId && (
|
||||
<>
|
||||
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
|
||||
|
||||
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingSapronakTabContent;
|
||||
@@ -0,0 +1,280 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import Button from '@/components/Button';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { Closing } from '@/types/api/closing';
|
||||
|
||||
const PROJECT_STATUS_OPTIONS = [
|
||||
{
|
||||
value: 1,
|
||||
label: 'Pengajuan',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: 'Aktif',
|
||||
},
|
||||
];
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
props,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
props: CellContext<Closing, unknown>;
|
||||
}) => {
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
|
||||
<RequirePermission permissions='lti.closing.detail'>
|
||||
<Button
|
||||
href={`/closing/detail/?closingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ClosingsTable = () => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
nameSort: '',
|
||||
transactionDate: '',
|
||||
realizationDate: '',
|
||||
locationId: '',
|
||||
projectStatus: '',
|
||||
userId: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
transactionDate: 'transaction_date',
|
||||
realizationDate: 'realization_date',
|
||||
locationId: 'location_id',
|
||||
projectStatus: 'project_status',
|
||||
userId: 'user_id',
|
||||
},
|
||||
});
|
||||
|
||||
const { data: closings, isLoading: isLoadingClosings } = useSWR(
|
||||
`${ClosingApi.basePath}${getTableFilterQueryString()}`,
|
||||
ClosingApi.getAllFetcher
|
||||
);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
|
||||
const closingsColumns: ColumnDef<Closing>[] = [
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) => props.row.index + 1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'location_name',
|
||||
header: 'Lokasi',
|
||||
},
|
||||
{
|
||||
accessorKey: 'project_category',
|
||||
header: 'Kategori',
|
||||
},
|
||||
{
|
||||
accessorKey: 'period',
|
||||
header: 'Periode',
|
||||
},
|
||||
{
|
||||
accessorKey: 'closing_date',
|
||||
header: 'Periode',
|
||||
cell: (props) =>
|
||||
formatDate(props.row.original.closing_date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'shed_label',
|
||||
header: 'Jumlah Kandang',
|
||||
},
|
||||
{
|
||||
accessorKey: 'project_status',
|
||||
header: 'Status',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props) => {
|
||||
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||
const currentRowRelativeIndex =
|
||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 3 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu type='dropdown' props={props} />
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 3 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu type='collapse' props={props} />
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
setSelectedLocation(val as OptionType);
|
||||
updateFilter(
|
||||
'locationId',
|
||||
val ? ((val as OptionType).value as string) : ''
|
||||
);
|
||||
};
|
||||
|
||||
const [selectedProjectStatus, setSelectedProjectStatus] =
|
||||
useState<OptionType | null>(null);
|
||||
|
||||
const projectStatusChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setSelectedProjectStatus(val as OptionType);
|
||||
updateFilter(
|
||||
'projectStatus',
|
||||
val ? ((val as OptionType).value as string) : ''
|
||||
);
|
||||
};
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
|
||||
if (!isNameSorted) {
|
||||
updateFilter('nameSort', '');
|
||||
} else {
|
||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||
}
|
||||
}, [sorting, updateFilter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full p-0 sm:p-4'>
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-end items-end sm:items-center gap-4'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Closing'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-12 justify-end gap-2'>
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
options={locationOptions}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
value={selectedLocation}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-6',
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Status Project'
|
||||
placeholder='Pilih Status'
|
||||
options={PROJECT_STATUS_OPTIONS}
|
||||
value={selectedProjectStatus}
|
||||
onChange={projectStatusChangeHandler}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-6',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<Closing>
|
||||
data={isResponseSuccess(closings) ? closings?.data : []}
|
||||
columns={closingsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
page={isResponseSuccess(closings) ? closings?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(closings) ? closings?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingClosings}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(closings) && closings?.data?.length === 0,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingsTable;
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import Table from '@/components/Table';
|
||||
import Card from '@/components/Card';
|
||||
import { formatCurrency } from '@/lib/helper';
|
||||
import { BaseHppExpedition, BaseExpeditionCost } from '@/types/api/closing';
|
||||
|
||||
interface HppExpeditionReportTableProps {
|
||||
type?: 'detail';
|
||||
initialValues?: BaseHppExpedition;
|
||||
}
|
||||
|
||||
const HppExpeditionReportTable = ({
|
||||
type = 'detail',
|
||||
initialValues,
|
||||
}: HppExpeditionReportTableProps) => {
|
||||
const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => {
|
||||
return initialValues?.expedition_costs || [];
|
||||
}, [initialValues]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalHpp = initialValues?.total_hpp_amount || 0;
|
||||
|
||||
return {
|
||||
totalHpp,
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
const costOfRevenueExpeditionColumns: ColumnDef<BaseExpeditionCost>[] =
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorKey: 'id',
|
||||
header: 'No',
|
||||
cell: (props) => {
|
||||
return <div>{props.row.index + 1}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
Total HPP Ekspedisi
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expedition_vendor_name',
|
||||
accessorKey: 'expedition_vendor_name',
|
||||
header: 'Nama Ekspedisi',
|
||||
cell: (props) => props.getValue() || '-',
|
||||
},
|
||||
{
|
||||
id: 'hpp_amount',
|
||||
accessorKey: 'hpp_amount',
|
||||
header: 'HPP Ekspedisi',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(totals.totalHpp)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[totals]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full'>
|
||||
<div className='p-4'>
|
||||
<h2 className='text-xl font-semibold mb-4'>HPP Ekspedisi</h2>
|
||||
<Card
|
||||
className={{
|
||||
wrapper: 'w-full bg-base-100',
|
||||
body: 'p-0',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
data={costOfRevenueExpeditionData}
|
||||
columns={costOfRevenueExpeditionColumns}
|
||||
renderFooter={costOfRevenueExpeditionData.length > 0}
|
||||
className={{
|
||||
tableWrapperClassName: 'overflow-x-auto',
|
||||
tableClassName: 'w-full table-auto text-sm',
|
||||
headerRowClassName: 'border-b border-b-gray-200',
|
||||
headerColumnClassName:
|
||||
'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap',
|
||||
bodyRowClassName:
|
||||
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
tableFooterClassName:
|
||||
'bg-gray-100 font-semibold border border-gray-200',
|
||||
footerRowClassName: 'border-t-2 border-gray-300',
|
||||
footerColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HppExpeditionReportTable;
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import Table from '@/components/Table';
|
||||
import Card from '@/components/Card';
|
||||
import Badge from '@/components/Badge';
|
||||
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
|
||||
import { BaseClosingSales, BaseSales } from '@/types/api/closing';
|
||||
import { Product } from '@/types/api/master-data/product';
|
||||
import { Customer } from '@/types/api/master-data/customer';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
|
||||
interface SalesReportTableProps {
|
||||
type?: 'detail';
|
||||
initialValues?: BaseClosingSales;
|
||||
}
|
||||
|
||||
const SalesReportTable = ({
|
||||
type = 'detail',
|
||||
initialValues,
|
||||
}: SalesReportTableProps) => {
|
||||
const salesData: BaseSales[] = useMemo(() => {
|
||||
return initialValues?.sales || [];
|
||||
}, [initialValues]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (salesData.length === 0) {
|
||||
return {
|
||||
totalQuantity: 0,
|
||||
totalWeight: 0,
|
||||
avgWeight: 0,
|
||||
avgPricePartner: 0,
|
||||
totalPartner: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalQuantity = salesData.reduce(
|
||||
(sum, item) => sum + (item.qty || 0),
|
||||
0
|
||||
);
|
||||
const totalWeight = salesData.reduce(
|
||||
(sum, item) => sum + (item.weight || 0),
|
||||
0
|
||||
);
|
||||
const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0;
|
||||
|
||||
const validPriceItems = salesData.filter(
|
||||
(item) => item.price != null && item.price > 0
|
||||
);
|
||||
const avgPricePartner =
|
||||
validPriceItems.length > 0
|
||||
? validPriceItems.reduce((sum, item) => sum + item.price, 0) /
|
||||
validPriceItems.length
|
||||
: 0;
|
||||
|
||||
const totalPartner = salesData.reduce(
|
||||
(sum, item) => sum + (item.total_price || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
totalQuantity,
|
||||
totalWeight,
|
||||
avgWeight,
|
||||
avgPricePartner,
|
||||
totalPartner,
|
||||
};
|
||||
}, [salesData]);
|
||||
|
||||
const salesColumns: ColumnDef<BaseSales>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'realization_date',
|
||||
accessorKey: 'realization_date',
|
||||
header: 'Tanggal Realisasi',
|
||||
cell: (props) => {
|
||||
const date = props.row.original.realization_date;
|
||||
return date ? formatDate(date, 'DD MMM YYYY') : '-';
|
||||
},
|
||||
footer: () => (
|
||||
<div className='font-semibold text-gray-900'>Total Penjualan</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'age',
|
||||
accessorKey: 'age',
|
||||
header: 'Umur',
|
||||
cell: (props) => props.getValue() || '-',
|
||||
},
|
||||
{
|
||||
id: 'do_number',
|
||||
accessorKey: 'do_number',
|
||||
header: 'No. DO',
|
||||
cell: (props) => props.getValue() || '-',
|
||||
},
|
||||
{
|
||||
id: 'product',
|
||||
accessorKey: 'product',
|
||||
header: 'Produk',
|
||||
cell: (props) => {
|
||||
const product = props.getValue() as Product;
|
||||
return product?.name || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorKey: 'customer',
|
||||
header: 'Customer',
|
||||
cell: (props) => {
|
||||
const customer = props.getValue() as Customer;
|
||||
return customer?.name || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'jumlah',
|
||||
header: 'Jumlah',
|
||||
columns: [
|
||||
{
|
||||
id: 'qty',
|
||||
accessorKey: 'qty',
|
||||
header: 'Kuantitas',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-left'>{formatNumber(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-left font-semibold text-gray-900'>
|
||||
{formatNumber(totals.totalQuantity)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
accessorKey: 'weight',
|
||||
header: 'Kg',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-left'>{formatNumber(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-left font-semibold text-gray-900'>
|
||||
{formatNumber(totals.totalWeight)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'avg_weight',
|
||||
accessorKey: 'avg_weight',
|
||||
header: 'AVG (Kg)',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-left'>{formatNumber(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-left font-semibold text-gray-900'>
|
||||
{formatNumber(totals.avgWeight)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price_partner',
|
||||
accessorKey: 'price',
|
||||
header: 'Harga Mitra (Rp)',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(totals.avgPricePartner)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'total_mitra',
|
||||
accessorKey: 'total_price',
|
||||
header: 'Total Mitra (Rp)',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(totals.totalPartner)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'price_act',
|
||||
accessorKey: 'price',
|
||||
header: 'Harga Act (Rp)',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'total_act',
|
||||
accessorKey: 'total_price',
|
||||
header: 'Total Act (Rp)',
|
||||
cell: (props) => {
|
||||
const value = props.getValue() as number;
|
||||
return <div className='text-right'>{formatCurrency(value)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kandang',
|
||||
accessorKey: 'kandang',
|
||||
header: 'Kandang',
|
||||
cell: (props) => {
|
||||
const kandang = props.getValue() as Kandang;
|
||||
return kandang?.name || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'payment_status',
|
||||
accessorKey: 'payment_status',
|
||||
header: 'Status Pembayaran',
|
||||
cell: (props) => {
|
||||
const status = props.getValue() as string;
|
||||
const getStatusColor = (status: string) => {
|
||||
if (!status) return 'neutral';
|
||||
switch (status.toLowerCase()) {
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'tempo':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'neutral';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant='soft' size='sm' color={getStatusColor(status)}>
|
||||
{status || '-'}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full'>
|
||||
<div className='p-4'>
|
||||
<h2 className='text-xl font-semibold mb-4'>Penjualan</h2>
|
||||
<Card
|
||||
className={{
|
||||
wrapper: 'w-full bg-base-100',
|
||||
body: 'p-0',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
data={salesData}
|
||||
columns={salesColumns}
|
||||
renderFooter={salesData.length > 0}
|
||||
className={{
|
||||
tableWrapperClassName: 'overflow-x-auto',
|
||||
tableClassName: 'w-full table-auto text-sm',
|
||||
headerColumnClassName:
|
||||
'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
|
||||
bodyRowClassName:
|
||||
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
tableFooterClassName:
|
||||
'bg-gray-100 font-semibold border border-gray-200',
|
||||
footerRowClassName: 'border-t-2 border-gray-300',
|
||||
footerColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesReportTable;
|
||||
@@ -0,0 +1,399 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import { Icon } from '@iconify/react';
|
||||
import ProductionLineChart from '@/components/pages/dashboard/chart/ProductionLineChart';
|
||||
import StandardLineChart from '@/components/pages/dashboard/chart/StandardLineChart';
|
||||
import EggWeightBarChart from '@/components/pages/dashboard/chart/EggWeightBarChart';
|
||||
import FCRBarChart from '@/components/pages/dashboard/chart/FCRBarChart';
|
||||
import ProductionStat from '@/components/pages/dashboard/chart/ProductionStat';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { RadioGroup } from '@/components/input/RadioInput';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardApi } from '@/services/api/dashboard';
|
||||
import { useFormik } from 'formik';
|
||||
import dashboardProductionFilterSchema from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { ProductionStandardApi } from '@/services/api/master-data';
|
||||
|
||||
const DashboardProduction = () => {
|
||||
const filterModal = useModal();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('daily');
|
||||
const [selectedStandards, setSelectedStandards] = useState<string[]>([
|
||||
'hen_day',
|
||||
'hen_house',
|
||||
]);
|
||||
const [endpointUrl, setEndpointUrl] = useState('/dashboard');
|
||||
|
||||
// ===== FETCH DATA =====
|
||||
const {
|
||||
data: dashboardProductionResponse,
|
||||
isLoading: isLoadingDashboardProductionData,
|
||||
error: dashboardProductionError,
|
||||
} = useSWR(endpointUrl, () =>
|
||||
DashboardApi.getDashboardProductionFetcher(endpointUrl)
|
||||
);
|
||||
|
||||
const dashboardProductionData =
|
||||
dashboardProductionResponse?.status === 'success'
|
||||
? dashboardProductionResponse.data
|
||||
: undefined;
|
||||
|
||||
// ===== SELECT =====
|
||||
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
|
||||
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
|
||||
limit: 'limit',
|
||||
category: 'LAYING',
|
||||
});
|
||||
const {
|
||||
options: standardProductionOptions,
|
||||
isLoadingOptions: isLoadingStandardProductionOptions,
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
|
||||
// ===== FORMIK =====
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
flock: [] as OptionType[],
|
||||
standard_production_id: [] as OptionType[],
|
||||
standard_productions: [] as OptionType[],
|
||||
period: selectedPeriod,
|
||||
},
|
||||
validationSchema: dashboardProductionFilterSchema,
|
||||
onSubmit: (values) => {
|
||||
console.log(values);
|
||||
// Build URL with query parameters
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (values.startDate) params.set('startDate', values.startDate);
|
||||
if (values.endDate) params.set('endDate', values.endDate);
|
||||
|
||||
if (values.flock && values.flock.length > 0) {
|
||||
const flockIds = values.flock
|
||||
.map((f: OptionType) => f.value || f)
|
||||
.join(',');
|
||||
params.set('flock', flockIds);
|
||||
}
|
||||
|
||||
if (
|
||||
values.standard_production_id &&
|
||||
values.standard_production_id.length > 0
|
||||
) {
|
||||
const standardIds = values.standard_production_id
|
||||
.map((s: OptionType) => s.value || s)
|
||||
.join(',');
|
||||
params.set('standard_production_id', standardIds);
|
||||
}
|
||||
|
||||
if (selectedStandards.length > 0) {
|
||||
params.set('standards', selectedStandards.join(','));
|
||||
}
|
||||
|
||||
params.set('period', selectedPeriod);
|
||||
|
||||
const newUrl = `/dashboard?${params.toString()}`;
|
||||
setEndpointUrl(newUrl);
|
||||
|
||||
// Close modal after applying filter
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const handleResetFilter = () => {
|
||||
formik.resetForm();
|
||||
setSelectedPeriod('daily');
|
||||
setSelectedStandards(['hen_day', 'hen_house']);
|
||||
setEndpointUrl('/dashboard');
|
||||
};
|
||||
|
||||
if (isLoadingDashboardProductionData) {
|
||||
return (
|
||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||
<span className='loading loading-spinner loading-xl'></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<section className='w-full p-4 space-y-6'>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
|
||||
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
|
||||
<div className='flex flex-row justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='min-w-28 rounded-lg'
|
||||
onClick={() => filterModal.openModal()}
|
||||
>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
Filter
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='neutral'
|
||||
className='min-w-28 rounded-lg'
|
||||
>
|
||||
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} />
|
||||
Export
|
||||
<Icon icon='heroicons:chevron-down' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Statistics */}
|
||||
<ProductionStat data={dashboardProductionData?.statistics_data} />
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
{/* Production Line Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<ProductionLineChart
|
||||
period={
|
||||
selectedPeriod as 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
}
|
||||
data={dashboardProductionData?.production_charts}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Standard Line Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<StandardLineChart
|
||||
selectedStandards={selectedStandards}
|
||||
data={dashboardProductionData?.standard_productions}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Bar Charts Grid - 2 columns */}
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||||
{/* FCR Bar Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<FCRBarChart data={dashboardProductionData?.fcr_data} />
|
||||
</Card>
|
||||
|
||||
{/* Egg Weight Bar Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<EggWeightBarChart data={dashboardProductionData?.egg_weights} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-xl',
|
||||
}}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300'>
|
||||
<div className='flex items-center gap-2 ms-4'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-semibold'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={() => filterModal.closeModal()}
|
||||
className='text-gray-500 hover:text-gray-700 me-4 '
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form className='space-y-4' onSubmit={formik.handleSubmit}>
|
||||
{/* Rentang Waktu */}
|
||||
<div className='px-4'>
|
||||
<label className='flex items-center gap-2 mb-3'>
|
||||
<Icon icon='heroicons:calendar' width={20} height={20} />
|
||||
Rentang Waktu
|
||||
</label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<DateInput
|
||||
name='startDate'
|
||||
placeholder='Tanggal Mulai'
|
||||
value={formik.values.startDate}
|
||||
errorMessage={formik.errors.startDate}
|
||||
onChange={formik.handleChange}
|
||||
className={{
|
||||
inputWrapper: 'rounded-lg',
|
||||
}}
|
||||
isError={
|
||||
Boolean(formik.errors.startDate) &&
|
||||
Boolean(formik.touched.startDate)
|
||||
}
|
||||
/>
|
||||
<span className='hidden md:block text-center'>—</span>
|
||||
<DateInput
|
||||
name='endDate'
|
||||
placeholder='Tanggal Akhir'
|
||||
value={formik.values.endDate}
|
||||
errorMessage={formik.errors.endDate}
|
||||
onChange={formik.handleChange}
|
||||
className={{
|
||||
inputWrapper: 'rounded-lg',
|
||||
}}
|
||||
isError={
|
||||
Boolean(formik.errors.endDate) &&
|
||||
Boolean(formik.touched.endDate)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flock */}
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Flock'
|
||||
value={formik.values.flock}
|
||||
onChange={(selected) => formik.setFieldValue('flock', selected)}
|
||||
errorMessage={formik.errors.flock as string}
|
||||
options={flockOptions}
|
||||
isLoading={isLoadingFlockOptions}
|
||||
isMulti
|
||||
isError={
|
||||
Boolean(formik.errors.flock) && Boolean(formik.touched.flock)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Production */}
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Standard Produksi'
|
||||
value={formik.values.standard_production_id}
|
||||
onChange={(selected) =>
|
||||
formik.setFieldValue('standard_production_id', selected)
|
||||
}
|
||||
errorMessage={formik.errors.standard_production_id as string}
|
||||
options={standardProductionOptions}
|
||||
isLoading={isLoadingStandardProductionOptions}
|
||||
isMulti
|
||||
isError={
|
||||
Boolean(formik.errors.standard_production_id) &&
|
||||
Boolean(formik.touched.standard_production_id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Standard */}
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Standard'
|
||||
value={selectedStandards.map((s) => ({
|
||||
value: s,
|
||||
label:
|
||||
s === 'hen_day'
|
||||
? 'Hen Day'
|
||||
: s === 'hen_house'
|
||||
? 'Hen House'
|
||||
: s === 'uniformity'
|
||||
? 'Uniformity'
|
||||
: s === 'egg_weight'
|
||||
? 'Egg Weight'
|
||||
: 'Egg Mass',
|
||||
}))}
|
||||
options={[
|
||||
{ value: 'hen_day', label: 'Hen Day' },
|
||||
{ value: 'hen_house', label: 'Hen House' },
|
||||
{ value: 'uniformity', label: 'Uniformity' },
|
||||
{ value: 'egg_weight', label: 'Egg Weight' },
|
||||
{ value: 'egg_mass', label: 'Egg Mass' },
|
||||
]}
|
||||
isMulti
|
||||
onChange={(selected: OptionType | OptionType[] | null) => {
|
||||
const values = Array.isArray(selected)
|
||||
? selected.map((item) => String(item.value))
|
||||
: [];
|
||||
setSelectedStandards(
|
||||
values.length > 0 ? values : ['hen_day']
|
||||
);
|
||||
}}
|
||||
isError={
|
||||
Boolean(formik.errors.standard_productions) &&
|
||||
Boolean(formik.touched.standard_productions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Periode Perbandingan */}
|
||||
<div className='px-4'>
|
||||
<label className='block mb-3'>Periode Perbandingan</label>
|
||||
<div className='grid grid-cols-4 gap-2'>
|
||||
<Button
|
||||
variant={selectedPeriod === 'daily' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('daily')}
|
||||
>
|
||||
Harian
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedPeriod === 'weekly' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('weekly')}
|
||||
>
|
||||
Mingguan
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedPeriod === 'monthly' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('monthly')}
|
||||
>
|
||||
Bulanan
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedPeriod === 'yearly' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('yearly')}
|
||||
>
|
||||
Tahunan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
|
||||
<Button
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='ms-4 min-w-36 rounded-lg'
|
||||
onClick={handleResetFilter}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button type='submit' className='me-4 min-w-36 rounded-lg'>
|
||||
Terapkan Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardProduction;
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { DashboardProductionEggWeights } from '@/types/api/dashboard/dashboard-production';
|
||||
|
||||
interface EggWeightBarChartProps {
|
||||
data?: DashboardProductionEggWeights[];
|
||||
}
|
||||
|
||||
const EggWeightBarChart = ({ data }: EggWeightBarChartProps) => {
|
||||
// Show loading state if no data
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<h3 className='text-lg font-semibold mb-4'>
|
||||
Rata-rata Berat Telur (EW)
|
||||
</h3>
|
||||
<div className='flex items-center justify-center h-[350px]'>
|
||||
<p className='text-gray-500'>Memuat data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<h3 className='text-lg font-semibold mb-4'>Rata-rata Berat Telur (EW)</h3>
|
||||
<ResponsiveContainer width='100%' height={350}>
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
|
||||
<XAxis
|
||||
dataKey='flock.name'
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
domain={[0, 'auto']}
|
||||
label={{
|
||||
value: 'Berat (gram)',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
formatter={(value: number | undefined) =>
|
||||
value !== undefined ? [`${value} gram`, ''] : ['', '']
|
||||
}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }}
|
||||
/>
|
||||
<Bar dataKey='weight' radius={[8, 8, 0, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill='#3b82f6' />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EggWeightBarChart;
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { DashboardProductionFcrData } from '@/types/api/dashboard/dashboard-production';
|
||||
|
||||
interface FCRBarChartProps {
|
||||
data?: DashboardProductionFcrData[];
|
||||
}
|
||||
|
||||
// Alternating colors: green and red
|
||||
const colors = ['#10b981', '#ef4444'];
|
||||
|
||||
const FCRBarChart = ({ data }: FCRBarChartProps) => {
|
||||
// Show loading state if no data
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<h3 className='text-lg font-semibold mb-4'>
|
||||
Feed Conversion Ratio (FCR)
|
||||
</h3>
|
||||
<div className='flex items-center justify-center h-[350px]'>
|
||||
<p className='text-gray-500'>Memuat data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<h3 className='text-lg font-semibold mb-4'>
|
||||
Feed Conversion Ratio (FCR)
|
||||
</h3>
|
||||
<ResponsiveContainer width='100%' height={350}>
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
|
||||
<XAxis
|
||||
dataKey='flock.name'
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
domain={[0, 'auto']}
|
||||
label={{
|
||||
value: 'FCR',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
formatter={(value: number | undefined) =>
|
||||
value !== undefined ? [value.toFixed(2), 'FCR'] : ['', '']
|
||||
}
|
||||
cursor={{ fill: 'rgba(16, 185, 129, 0.1)' }}
|
||||
/>
|
||||
<Bar dataKey='fcr' radius={[8, 8, 0, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={colors[index % colors.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FCRBarChart;
|
||||
@@ -0,0 +1,357 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// Sample data in API format
|
||||
const sampleApiData: ProductionChartItem[] = [
|
||||
{
|
||||
date: '2025-12-01T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 88 },
|
||||
{ id: 2, name: 'Flock A-001', data: 92 },
|
||||
{ id: 3, name: 'Flock B-001', data: 90 },
|
||||
{ id: 4, name: 'Flock B-002', data: 85 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-03T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 85 },
|
||||
{ id: 2, name: 'Flock A-001', data: 95 },
|
||||
{ id: 3, name: 'Flock B-001', data: 93 },
|
||||
{ id: 4, name: 'Flock B-002', data: 87 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-05T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 82 },
|
||||
{ id: 2, name: 'Flock A-001', data: 98 },
|
||||
{ id: 3, name: 'Flock B-001', data: 91 },
|
||||
{ id: 4, name: 'Flock B-002', data: 84 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-07T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 80 },
|
||||
{ id: 2, name: 'Flock A-001', data: 89 },
|
||||
{ id: 3, name: 'Flock B-001', data: 88 },
|
||||
{ id: 4, name: 'Flock B-002', data: 82 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-08T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 83 },
|
||||
{ id: 2, name: 'Flock A-001', data: 92 },
|
||||
{ id: 3, name: 'Flock B-001', data: 95 },
|
||||
{ id: 4, name: 'Flock B-002', data: 85 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-11T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 81 },
|
||||
{ id: 2, name: 'Flock A-001', data: 88 },
|
||||
{ id: 3, name: 'Flock B-001', data: 92 },
|
||||
{ id: 4, name: 'Flock B-002', data: 83 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-13T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 84 },
|
||||
{ id: 2, name: 'Flock A-001', data: 90 },
|
||||
{ id: 3, name: 'Flock B-001', data: 89 },
|
||||
{ id: 4, name: 'Flock B-002', data: 86 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-15T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 82 },
|
||||
{ id: 2, name: 'Flock A-001', data: 94 },
|
||||
{ id: 3, name: 'Flock B-001', data: 96 },
|
||||
{ id: 4, name: 'Flock B-002', data: 84 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-17T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 80 },
|
||||
{ id: 2, name: 'Flock A-001', data: 91 },
|
||||
{ id: 3, name: 'Flock B-001', data: 93 },
|
||||
{ id: 4, name: 'Flock B-002', data: 82 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-19T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 79 },
|
||||
{ id: 2, name: 'Flock A-001', data: 88 },
|
||||
{ id: 3, name: 'Flock B-001', data: 90 },
|
||||
{ id: 4, name: 'Flock B-002', data: 81 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-21T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 81 },
|
||||
{ id: 2, name: 'Flock A-001', data: 97 },
|
||||
{ id: 3, name: 'Flock B-001', data: 92 },
|
||||
{ id: 4, name: 'Flock B-002', data: 83 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-23T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 83 },
|
||||
{ id: 2, name: 'Flock A-001', data: 95 },
|
||||
{ id: 3, name: 'Flock B-001', data: 98 },
|
||||
{ id: 4, name: 'Flock B-002', data: 85 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-25T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 80 },
|
||||
{ id: 2, name: 'Flock A-001', data: 89 },
|
||||
{ id: 3, name: 'Flock B-001', data: 94 },
|
||||
{ id: 4, name: 'Flock B-002', data: 82 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-27T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 82 },
|
||||
{ id: 2, name: 'Flock A-001', data: 93 },
|
||||
{ id: 3, name: 'Flock B-001', data: 96 },
|
||||
{ id: 4, name: 'Flock B-002', data: 84 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2025-12-28T00:00:00Z',
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-002', data: 85 },
|
||||
{ id: 2, name: 'Flock A-001', data: 96 },
|
||||
{ id: 3, name: 'Flock B-001', data: 95 },
|
||||
{ id: 4, name: 'Flock B-002', data: 87 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to format date based on period
|
||||
const formatDateByPeriod = (
|
||||
dateString: string,
|
||||
period: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
): string => {
|
||||
const date = new Date(dateString);
|
||||
const monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'Mei',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Agu',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Des',
|
||||
];
|
||||
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
// Format: "1 Des"
|
||||
return `${date.getDate()} ${monthNames[date.getMonth()]}`;
|
||||
|
||||
case 'weekly':
|
||||
// Format: "Week 1 Des"
|
||||
const weekNumber = Math.ceil(date.getDate() / 7);
|
||||
return `Week ${weekNumber} ${monthNames[date.getMonth()]}`;
|
||||
|
||||
case 'monthly':
|
||||
// Format: "Des"
|
||||
return monthNames[date.getMonth()];
|
||||
|
||||
case 'yearly':
|
||||
// Format: "2025"
|
||||
return date.getFullYear().toString();
|
||||
|
||||
default:
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Type definitions for API data
|
||||
interface FlockData {
|
||||
id: number;
|
||||
name: string;
|
||||
data: number;
|
||||
}
|
||||
|
||||
interface ProductionChartItem {
|
||||
date: string;
|
||||
flocks: FlockData[];
|
||||
}
|
||||
|
||||
interface ProductionChartsData {
|
||||
production_charts: ProductionChartItem[];
|
||||
}
|
||||
|
||||
// Transform API data to Recharts format
|
||||
const transformProductionData = (apiData: ProductionChartItem[]) => {
|
||||
return apiData.map((item) => {
|
||||
const transformed: Record<string, string | number> = {
|
||||
date: item.date.split('T')[0], // Extract YYYY-MM-DD from ISO string
|
||||
};
|
||||
|
||||
// Add each flock's data as a property
|
||||
item.flocks.forEach((flock) => {
|
||||
transformed[flock.name] = flock.data;
|
||||
});
|
||||
|
||||
return transformed;
|
||||
});
|
||||
};
|
||||
|
||||
interface ProductionLineChartProps {
|
||||
period?: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
data?: ProductionChartItem[]; // Optional API data
|
||||
}
|
||||
|
||||
const ProductionLineChart = ({
|
||||
period = 'daily',
|
||||
data: apiData,
|
||||
}: ProductionLineChartProps) => {
|
||||
// State to track which lines are hidden
|
||||
const [hiddenLines, setHiddenLines] = useState<string[]>([]);
|
||||
|
||||
// Use API data if provided, otherwise use sample data
|
||||
const chartData = apiData
|
||||
? transformProductionData(apiData)
|
||||
: transformProductionData(sampleApiData);
|
||||
|
||||
// Handle legend click to show/hide lines
|
||||
const handleLegendClick = (dataKey: string) => {
|
||||
setHiddenLines((prev) =>
|
||||
prev.includes(dataKey)
|
||||
? prev.filter((key) => key !== dataKey)
|
||||
: [...prev, dataKey]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<h3 className='text-lg font-semibold mb-4'>
|
||||
Performa Produksi per Flock
|
||||
</h3>
|
||||
<ResponsiveContainer width='100%' height={400}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={(value) => formatDateByPeriod(value, period)}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
domain={[0, 100]}
|
||||
label={{
|
||||
value: 'Persentase (%)',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
labelFormatter={(value) =>
|
||||
formatDateByPeriod(value as string, period)
|
||||
}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
paddingTop: '20px',
|
||||
}}
|
||||
iconType='circle'
|
||||
onClick={(e) => {
|
||||
if (e.dataKey) handleLegendClick(e.dataKey as string);
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock A-002'
|
||||
stroke='#3b82f6'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#3b82f6' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock A-002')}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock A-001'
|
||||
stroke='#10b981'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#10b981' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock A-001')}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock B-001'
|
||||
stroke='#f59e0b'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#f59e0b' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock B-001')}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock B-002'
|
||||
stroke='#ef4444'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#ef4444' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock B-002')}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionLineChart;
|
||||
|
||||
// Export types for external use
|
||||
export type { FlockData, ProductionChartItem, ProductionChartsData };
|
||||
@@ -0,0 +1,107 @@
|
||||
import Card from '@/components/Card';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { DashboardProductionStatisticsData } from '@/types/api/dashboard/dashboard-production';
|
||||
import { formatCurrency } from '@/lib/helper';
|
||||
|
||||
interface ProductionStatProps {
|
||||
data?: DashboardProductionStatisticsData[];
|
||||
}
|
||||
|
||||
const ProductionStat = ({ data }: ProductionStatProps) => {
|
||||
// Helper function to get icon based on title
|
||||
const getIcon = (title: string) => {
|
||||
if (title.toLowerCase().includes('keuangan'))
|
||||
return 'heroicons:currency-dollar';
|
||||
if (title.toLowerCase().includes('penjualan'))
|
||||
return 'heroicons:arrow-trending-up';
|
||||
if (title.toLowerCase().includes('pembelian'))
|
||||
return 'heroicons:shopping-cart';
|
||||
if (title.toLowerCase().includes('overhead')) return 'heroicons:calculator';
|
||||
return 'heroicons:chart-bar';
|
||||
};
|
||||
|
||||
// Helper function to get icon background color
|
||||
const getIconBgColor = (title: string) => {
|
||||
if (title.toLowerCase().includes('keuangan')) return 'bg-blue-500';
|
||||
if (title.toLowerCase().includes('penjualan')) return 'bg-green-500';
|
||||
if (title.toLowerCase().includes('pembelian')) return 'bg-orange-500';
|
||||
if (title.toLowerCase().includes('overhead')) return 'bg-purple-500';
|
||||
return 'bg-gray-500';
|
||||
};
|
||||
|
||||
// Show loading state if no data
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<section className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4'>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card
|
||||
key={i}
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-4' }}
|
||||
>
|
||||
<div className='animate-pulse'>
|
||||
<div className='h-4 bg-gray-200 rounded w-1/2 mb-2'></div>
|
||||
<div className='h-6 bg-gray-200 rounded w-3/4 mb-1'></div>
|
||||
<div className='h-4 bg-gray-200 rounded w-1/3'></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4'>
|
||||
{data.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-4' }}
|
||||
>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex-1'>
|
||||
<p className='text-sm text-gray-600 mb-2'>{stat.title}</p>
|
||||
<p className='text-xl font-bold text-gray-900 mb-1'>
|
||||
{formatCurrency(stat.value)}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm flex items-center gap-1 ${
|
||||
stat.changeType === 'increase'
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
stat.changeType === 'increase'
|
||||
? 'heroicons:arrow-trending-up'
|
||||
: 'heroicons:arrow-trending-down'
|
||||
}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
{stat.change > 0 ? '+' : ''}
|
||||
{stat.change}% vs{' '}
|
||||
{stat.period === 'monthly' ? 'bulan lalu' : 'periode lalu'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-lg ${getIconBgColor(stat.title)} flex items-center justify-center`}
|
||||
>
|
||||
<Icon
|
||||
icon={getIcon(stat.title)}
|
||||
width={24}
|
||||
height={24}
|
||||
className='text-white'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionStat;
|
||||
@@ -0,0 +1,691 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// Type definitions for API data
|
||||
interface FlockData {
|
||||
id: number;
|
||||
name: string;
|
||||
data: number;
|
||||
}
|
||||
|
||||
interface StandardData {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface StandardChartItem {
|
||||
week: number;
|
||||
standards: StandardData[];
|
||||
flocks: FlockData[];
|
||||
}
|
||||
|
||||
// Sample data in API format
|
||||
const sampleApiData: StandardChartItem[] = [
|
||||
{
|
||||
week: 18,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 40 },
|
||||
{ name: 'hen_house', value: 38 },
|
||||
{ name: 'uniformity', value: 85 },
|
||||
{ name: 'egg_weight', value: 52 },
|
||||
{ name: 'egg_mass', value: 20 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 38 },
|
||||
{ id: 2, name: 'Flock A-002', data: 37 },
|
||||
{ id: 3, name: 'Flock B-001', data: 39 },
|
||||
{ id: 4, name: 'Flock B-002', data: 36 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 20,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 45 },
|
||||
{ name: 'hen_house', value: 43 },
|
||||
{ name: 'uniformity', value: 86 },
|
||||
{ name: 'egg_weight', value: 54 },
|
||||
{ name: 'egg_mass', value: 24 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 43 },
|
||||
{ id: 2, name: 'Flock A-002', data: 42 },
|
||||
{ id: 3, name: 'Flock B-001', data: 44 },
|
||||
{ id: 4, name: 'Flock B-002', data: 41 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 22,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 48 },
|
||||
{ name: 'hen_house', value: 46 },
|
||||
{ name: 'uniformity', value: 87 },
|
||||
{ name: 'egg_weight', value: 55 },
|
||||
{ name: 'egg_mass', value: 26 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 47 },
|
||||
{ id: 2, name: 'Flock A-002', data: 46 },
|
||||
{ id: 3, name: 'Flock B-001', data: 48 },
|
||||
{ id: 4, name: 'Flock B-002', data: 45 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 24,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 50 },
|
||||
{ name: 'hen_house', value: 48 },
|
||||
{ name: 'uniformity', value: 88 },
|
||||
{ name: 'egg_weight', value: 56 },
|
||||
{ name: 'egg_mass', value: 28 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 49 },
|
||||
{ id: 2, name: 'Flock A-002', data: 48 },
|
||||
{ id: 3, name: 'Flock B-001', data: 50 },
|
||||
{ id: 4, name: 'Flock B-002', data: 47 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 26,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 52 },
|
||||
{ name: 'hen_house', value: 50 },
|
||||
{ name: 'uniformity', value: 89 },
|
||||
{ name: 'egg_weight', value: 57 },
|
||||
{ name: 'egg_mass', value: 30 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 50 },
|
||||
{ id: 2, name: 'Flock A-002', data: 49 },
|
||||
{ id: 3, name: 'Flock B-001', data: 51 },
|
||||
{ id: 4, name: 'Flock B-002', data: 48 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 28,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 55 },
|
||||
{ name: 'hen_house', value: 53 },
|
||||
{ name: 'uniformity', value: 90 },
|
||||
{ name: 'egg_weight', value: 58 },
|
||||
{ name: 'egg_mass', value: 32 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 53 },
|
||||
{ id: 2, name: 'Flock A-002', data: 52 },
|
||||
{ id: 3, name: 'Flock B-001', data: 54 },
|
||||
{ id: 4, name: 'Flock B-002', data: 51 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 30,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 58 },
|
||||
{ name: 'hen_house', value: 56 },
|
||||
{ name: 'uniformity', value: 91 },
|
||||
{ name: 'egg_weight', value: 59 },
|
||||
{ name: 'egg_mass', value: 34 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 55 },
|
||||
{ id: 2, name: 'Flock A-002', data: 54 },
|
||||
{ id: 3, name: 'Flock B-001', data: 56 },
|
||||
{ id: 4, name: 'Flock B-002', data: 53 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 32,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 60 },
|
||||
{ name: 'hen_house', value: 58 },
|
||||
{ name: 'uniformity', value: 92 },
|
||||
{ name: 'egg_weight', value: 60 },
|
||||
{ name: 'egg_mass', value: 36 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 58 },
|
||||
{ id: 2, name: 'Flock A-002', data: 57 },
|
||||
{ id: 3, name: 'Flock B-001', data: 59 },
|
||||
{ id: 4, name: 'Flock B-002', data: 56 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 34,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 62 },
|
||||
{ name: 'hen_house', value: 60 },
|
||||
{ name: 'uniformity', value: 92 },
|
||||
{ name: 'egg_weight', value: 61 },
|
||||
{ name: 'egg_mass', value: 38 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 60 },
|
||||
{ id: 2, name: 'Flock A-002', data: 59 },
|
||||
{ id: 3, name: 'Flock B-001', data: 61 },
|
||||
{ id: 4, name: 'Flock B-002', data: 58 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 36,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 64 },
|
||||
{ name: 'hen_house', value: 62 },
|
||||
{ name: 'uniformity', value: 93 },
|
||||
{ name: 'egg_weight', value: 62 },
|
||||
{ name: 'egg_mass', value: 40 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 62 },
|
||||
{ id: 2, name: 'Flock A-002', data: 61 },
|
||||
{ id: 3, name: 'Flock B-001', data: 63 },
|
||||
{ id: 4, name: 'Flock B-002', data: 60 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 38,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 66 },
|
||||
{ name: 'hen_house', value: 64 },
|
||||
{ name: 'uniformity', value: 93 },
|
||||
{ name: 'egg_weight', value: 63 },
|
||||
{ name: 'egg_mass', value: 42 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 64 },
|
||||
{ id: 2, name: 'Flock A-002', data: 63 },
|
||||
{ id: 3, name: 'Flock B-001', data: 65 },
|
||||
{ id: 4, name: 'Flock B-002', data: 62 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 40,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 68 },
|
||||
{ name: 'hen_house', value: 66 },
|
||||
{ name: 'uniformity', value: 94 },
|
||||
{ name: 'egg_weight', value: 64 },
|
||||
{ name: 'egg_mass', value: 44 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 66 },
|
||||
{ id: 2, name: 'Flock A-002', data: 65 },
|
||||
{ id: 3, name: 'Flock B-001', data: 67 },
|
||||
{ id: 4, name: 'Flock B-002', data: 64 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 42,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 70 },
|
||||
{ name: 'hen_house', value: 68 },
|
||||
{ name: 'uniformity', value: 94 },
|
||||
{ name: 'egg_weight', value: 65 },
|
||||
{ name: 'egg_mass', value: 46 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 68 },
|
||||
{ id: 2, name: 'Flock A-002', data: 67 },
|
||||
{ id: 3, name: 'Flock B-001', data: 69 },
|
||||
{ id: 4, name: 'Flock B-002', data: 66 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 44,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 72 },
|
||||
{ name: 'hen_house', value: 70 },
|
||||
{ name: 'uniformity', value: 95 },
|
||||
{ name: 'egg_weight', value: 66 },
|
||||
{ name: 'egg_mass', value: 48 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 70 },
|
||||
{ id: 2, name: 'Flock A-002', data: 69 },
|
||||
{ id: 3, name: 'Flock B-001', data: 71 },
|
||||
{ id: 4, name: 'Flock B-002', data: 68 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 46,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 74 },
|
||||
{ name: 'hen_house', value: 72 },
|
||||
{ name: 'uniformity', value: 95 },
|
||||
{ name: 'egg_weight', value: 67 },
|
||||
{ name: 'egg_mass', value: 50 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 72 },
|
||||
{ id: 2, name: 'Flock A-002', data: 71 },
|
||||
{ id: 3, name: 'Flock B-001', data: 73 },
|
||||
{ id: 4, name: 'Flock B-002', data: 70 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 48,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 76 },
|
||||
{ name: 'hen_house', value: 74 },
|
||||
{ name: 'uniformity', value: 95 },
|
||||
{ name: 'egg_weight', value: 68 },
|
||||
{ name: 'egg_mass', value: 52 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 74 },
|
||||
{ id: 2, name: 'Flock A-002', data: 73 },
|
||||
{ id: 3, name: 'Flock B-001', data: 75 },
|
||||
{ id: 4, name: 'Flock B-002', data: 72 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 50,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 78 },
|
||||
{ name: 'hen_house', value: 76 },
|
||||
{ name: 'uniformity', value: 96 },
|
||||
{ name: 'egg_weight', value: 69 },
|
||||
{ name: 'egg_mass', value: 54 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 76 },
|
||||
{ id: 2, name: 'Flock A-002', data: 75 },
|
||||
{ id: 3, name: 'Flock B-001', data: 77 },
|
||||
{ id: 4, name: 'Flock B-002', data: 74 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 52,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 80 },
|
||||
{ name: 'hen_house', value: 78 },
|
||||
{ name: 'uniformity', value: 96 },
|
||||
{ name: 'egg_weight', value: 70 },
|
||||
{ name: 'egg_mass', value: 56 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 78 },
|
||||
{ id: 2, name: 'Flock A-002', data: 77 },
|
||||
{ id: 3, name: 'Flock B-001', data: 79 },
|
||||
{ id: 4, name: 'Flock B-002', data: 76 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 54,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 82 },
|
||||
{ name: 'hen_house', value: 80 },
|
||||
{ name: 'uniformity', value: 96 },
|
||||
{ name: 'egg_weight', value: 71 },
|
||||
{ name: 'egg_mass', value: 58 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 80 },
|
||||
{ id: 2, name: 'Flock A-002', data: 79 },
|
||||
{ id: 3, name: 'Flock B-001', data: 81 },
|
||||
{ id: 4, name: 'Flock B-002', data: 78 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 56,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 84 },
|
||||
{ name: 'hen_house', value: 82 },
|
||||
{ name: 'uniformity', value: 97 },
|
||||
{ name: 'egg_weight', value: 72 },
|
||||
{ name: 'egg_mass', value: 60 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 82 },
|
||||
{ id: 2, name: 'Flock A-002', data: 81 },
|
||||
{ id: 3, name: 'Flock B-001', data: 83 },
|
||||
{ id: 4, name: 'Flock B-002', data: 80 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 58,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 86 },
|
||||
{ name: 'hen_house', value: 84 },
|
||||
{ name: 'uniformity', value: 97 },
|
||||
{ name: 'egg_weight', value: 73 },
|
||||
{ name: 'egg_mass', value: 62 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 84 },
|
||||
{ id: 2, name: 'Flock A-002', data: 83 },
|
||||
{ id: 3, name: 'Flock B-001', data: 85 },
|
||||
{ id: 4, name: 'Flock B-002', data: 82 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 60,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 88 },
|
||||
{ name: 'hen_house', value: 86 },
|
||||
{ name: 'uniformity', value: 97 },
|
||||
{ name: 'egg_weight', value: 74 },
|
||||
{ name: 'egg_mass', value: 64 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 86 },
|
||||
{ id: 2, name: 'Flock A-002', data: 85 },
|
||||
{ id: 3, name: 'Flock B-001', data: 87 },
|
||||
{ id: 4, name: 'Flock B-002', data: 84 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 62,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 90 },
|
||||
{ name: 'hen_house', value: 88 },
|
||||
{ name: 'uniformity', value: 98 },
|
||||
{ name: 'egg_weight', value: 75 },
|
||||
{ name: 'egg_mass', value: 66 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 88 },
|
||||
{ id: 2, name: 'Flock A-002', data: 87 },
|
||||
{ id: 3, name: 'Flock B-001', data: 89 },
|
||||
{ id: 4, name: 'Flock B-002', data: 86 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 64,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 92 },
|
||||
{ name: 'hen_house', value: 90 },
|
||||
{ name: 'uniformity', value: 98 },
|
||||
{ name: 'egg_weight', value: 76 },
|
||||
{ name: 'egg_mass', value: 68 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 90 },
|
||||
{ id: 2, name: 'Flock A-002', data: 89 },
|
||||
{ id: 3, name: 'Flock B-001', data: 91 },
|
||||
{ id: 4, name: 'Flock B-002', data: 88 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 66,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 94 },
|
||||
{ name: 'hen_house', value: 92 },
|
||||
{ name: 'uniformity', value: 98 },
|
||||
{ name: 'egg_weight', value: 77 },
|
||||
{ name: 'egg_mass', value: 70 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 92 },
|
||||
{ id: 2, name: 'Flock A-002', data: 91 },
|
||||
{ id: 3, name: 'Flock B-001', data: 93 },
|
||||
{ id: 4, name: 'Flock B-002', data: 90 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 68,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 95 },
|
||||
{ name: 'hen_house', value: 93 },
|
||||
{ name: 'uniformity', value: 98 },
|
||||
{ name: 'egg_weight', value: 78 },
|
||||
{ name: 'egg_mass', value: 72 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 93 },
|
||||
{ id: 2, name: 'Flock A-002', data: 92 },
|
||||
{ id: 3, name: 'Flock B-001', data: 94 },
|
||||
{ id: 4, name: 'Flock B-002', data: 91 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 70,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 96 },
|
||||
{ name: 'hen_house', value: 94 },
|
||||
{ name: 'uniformity', value: 99 },
|
||||
{ name: 'egg_weight', value: 79 },
|
||||
{ name: 'egg_mass', value: 74 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 94 },
|
||||
{ id: 2, name: 'Flock A-002', data: 93 },
|
||||
{ id: 3, name: 'Flock B-001', data: 95 },
|
||||
{ id: 4, name: 'Flock B-002', data: 92 },
|
||||
],
|
||||
},
|
||||
{
|
||||
week: 72,
|
||||
standards: [
|
||||
{ name: 'hen_day', value: 97 },
|
||||
{ name: 'hen_house', value: 95 },
|
||||
{ name: 'uniformity', value: 99 },
|
||||
{ name: 'egg_weight', value: 80 },
|
||||
{ name: 'egg_mass', value: 76 },
|
||||
],
|
||||
flocks: [
|
||||
{ id: 1, name: 'Flock A-001', data: 95 },
|
||||
{ id: 2, name: 'Flock A-002', data: 94 },
|
||||
{ id: 3, name: 'Flock B-001', data: 96 },
|
||||
{ id: 4, name: 'Flock B-002', data: 93 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Transform API data to Recharts format
|
||||
const transformStandardData = (
|
||||
apiData: StandardChartItem[],
|
||||
selectedStandards: string[] = [
|
||||
'hen_day',
|
||||
'hen_house',
|
||||
'uniformity',
|
||||
'egg_weight',
|
||||
'egg_mass',
|
||||
]
|
||||
) => {
|
||||
return apiData.map((item) => {
|
||||
const transformed: Record<string, number> = {
|
||||
week: item.week,
|
||||
};
|
||||
|
||||
// Add selected standards as properties
|
||||
selectedStandards.forEach((standardName) => {
|
||||
const standardData = item.standards.find((s) => s.name === standardName);
|
||||
if (standardData) {
|
||||
transformed[standardName] = standardData.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Add each flock's data as a property
|
||||
item.flocks.forEach((flock) => {
|
||||
transformed[flock.name] = flock.data;
|
||||
});
|
||||
|
||||
return transformed;
|
||||
});
|
||||
};
|
||||
|
||||
interface StandardLineChartProps {
|
||||
data?: StandardChartItem[];
|
||||
selectedStandards?: string[];
|
||||
}
|
||||
|
||||
const StandardLineChart = ({
|
||||
data: apiData,
|
||||
selectedStandards = [
|
||||
'hen_day',
|
||||
'hen_house',
|
||||
'uniformity',
|
||||
'egg_weight',
|
||||
'egg_mass',
|
||||
],
|
||||
}: StandardLineChartProps) => {
|
||||
// State to track which lines are hidden
|
||||
const [hiddenLines, setHiddenLines] = useState<string[]>([]);
|
||||
|
||||
// Use API data if provided, otherwise use sample data
|
||||
const chartData = apiData
|
||||
? transformStandardData(apiData, selectedStandards)
|
||||
: transformStandardData(sampleApiData, selectedStandards);
|
||||
|
||||
// Handle legend click to show/hide lines
|
||||
const handleLegendClick = (dataKey: string) => {
|
||||
setHiddenLines((prev) =>
|
||||
prev.includes(dataKey)
|
||||
? prev.filter((key) => key !== dataKey)
|
||||
: [...prev, dataKey]
|
||||
);
|
||||
};
|
||||
|
||||
// Standard line colors mapping
|
||||
const standardColors: Record<string, string> = {
|
||||
hen_day: '#94a3b8',
|
||||
hen_house: '#64748b',
|
||||
uniformity: '#475569',
|
||||
egg_weight: '#334155',
|
||||
egg_mass: '#1e293b',
|
||||
};
|
||||
|
||||
// Standard names mapping for display
|
||||
const standardLabels: Record<string, string> = {
|
||||
hen_day: 'Hen Day',
|
||||
hen_house: 'Hen House',
|
||||
uniformity: 'Uniformity',
|
||||
egg_weight: 'Egg Weight',
|
||||
egg_mass: 'Egg Mass',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<h3 className='text-lg font-semibold mb-4'>
|
||||
Perbandingan Henday per Umur
|
||||
</h3>
|
||||
<ResponsiveContainer width='100%' height={400}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
|
||||
<XAxis
|
||||
dataKey='week'
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
label={{
|
||||
value: 'Umur (minggu)',
|
||||
position: 'insideBottom',
|
||||
offset: -5,
|
||||
style: { fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
domain={[0, 100]}
|
||||
label={{
|
||||
value: 'Henday (%)',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
formatter={(value: number | undefined) =>
|
||||
value !== undefined ? [`${value}%`, ''] : ['', '']
|
||||
}
|
||||
labelFormatter={(label) => `Minggu ${label}`}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
paddingTop: '20px',
|
||||
}}
|
||||
iconType='circle'
|
||||
onClick={(e) => {
|
||||
if (e.dataKey) handleLegendClick(e.dataKey as string);
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
{/* Dynamic Standard Lines */}
|
||||
{selectedStandards.map((standardName) => (
|
||||
<Line
|
||||
key={standardName}
|
||||
type='monotone'
|
||||
dataKey={standardName}
|
||||
name={standardLabels[standardName] || standardName}
|
||||
stroke={standardColors[standardName] || '#94a3b8'}
|
||||
strokeWidth={2}
|
||||
strokeDasharray='5 5'
|
||||
dot={{ r: 3, fill: standardColors[standardName] || '#94a3b8' }}
|
||||
activeDot={{ r: 5 }}
|
||||
hide={hiddenLines.includes(standardName)}
|
||||
/>
|
||||
))}
|
||||
{/* Flock Lines */}
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock A-002'
|
||||
stroke='#3b82f6'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#3b82f6' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock A-002')}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock A-001'
|
||||
stroke='#10b981'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#10b981' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock A-001')}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock B-001'
|
||||
stroke='#f59e0b'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#f59e0b' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock B-001')}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='Flock B-002'
|
||||
stroke='#ef4444'
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: '#ef4444' }}
|
||||
activeDot={{ r: 6 }}
|
||||
hide={hiddenLines.includes('Flock B-002')}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StandardLineChart;
|
||||
|
||||
// Export types for external use
|
||||
export type { FlockData, StandardData, StandardChartItem };
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
const dashboardProductionFilterSchema = yup.object({
|
||||
startDate: yup.string().optional(),
|
||||
endDate: yup.string().optional(),
|
||||
flock: yup.array().optional(),
|
||||
standard_production_id: yup.array().optional(),
|
||||
standard_productions: yup.array().optional(),
|
||||
period: yup.string().optional(),
|
||||
});
|
||||
|
||||
export type DashboardProductionFilterValues = yup.InferType<
|
||||
typeof dashboardProductionFilterSchema
|
||||
>;
|
||||
|
||||
export default dashboardProductionFilterSchema;
|
||||
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import Card from '@/components/Card';
|
||||
import DropFileInput from '@/components/input/DropFileInput';
|
||||
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
|
||||
interface ExpenseRealizationContentProps {
|
||||
initialValues?: Expense;
|
||||
@@ -62,16 +63,17 @@ const ExpenseRealizationContent = ({
|
||||
<div>
|
||||
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
||||
{/* TODO: apply RBAC */}
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
Edit Realisasi
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.update.realization'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
Edit Realisasi
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,59 +103,69 @@ const ExpenseRealizationContent = ({
|
||||
initialValues?.realization_docs.length > 0 && (
|
||||
<ul className='list-disc'>
|
||||
{initialValues?.realization_docs.map(
|
||||
(realizationDocument, realizationDocumentIdx) => (
|
||||
<li key={realizationDocumentIdx}>
|
||||
<Link
|
||||
href={realizationDocument.path}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{realizationDocument.path}{' '}
|
||||
<Icon
|
||||
icon='cuida:open-in-new-tab-outline'
|
||||
width={12}
|
||||
height={12}
|
||||
className='inline'
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
(realizationDocument, realizationDocumentIdx) => {
|
||||
const path = realizationDocument.path.startsWith(
|
||||
'/'
|
||||
)
|
||||
? realizationDocument.path.slice(1)
|
||||
: realizationDocument.path;
|
||||
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
|
||||
return (
|
||||
<li key={realizationDocumentIdx}>
|
||||
<Link
|
||||
href={documentUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{realizationDocument.path}{' '}
|
||||
<Icon
|
||||
icon='cuida:open-in-new-tab-outline'
|
||||
width={12}
|
||||
height={12}
|
||||
className='inline'
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<DropFileInput
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={realizationDocumentsChangeHandler}
|
||||
onDelete={realizationDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
maxFiles={10}
|
||||
className={{
|
||||
wrapper: 'mt-2',
|
||||
inputWrapper: 'flex items-center',
|
||||
}}
|
||||
/>
|
||||
<RequirePermission permissions='lti.expense.document.realization'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<DropFileInput
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={realizationDocumentsChangeHandler}
|
||||
onDelete={realizationDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
maxFiles={10}
|
||||
className={{
|
||||
wrapper: 'mt-2',
|
||||
inputWrapper: 'flex items-center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{formik.values.documents &&
|
||||
formik.values.documents.length > 0 && (
|
||||
<Button
|
||||
onClick={formik.submitForm}
|
||||
disabled={formik.isSubmitting}
|
||||
isLoading={formik.isSubmitting}
|
||||
className='w-fit self-end'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{formik.values.documents &&
|
||||
formik.values.documents.length > 0 && (
|
||||
<Button
|
||||
onClick={formik.submitForm}
|
||||
disabled={formik.isSubmitting}
|
||||
isLoading={formik.isSubmitting}
|
||||
className='w-fit self-end'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</RequirePermission>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -207,7 +219,7 @@ const ExpenseRealizationContent = ({
|
||||
let expenseGrandTotal = 0;
|
||||
|
||||
kandangExpense.pengajuans?.forEach(
|
||||
(item) => (expenseGrandTotal += item.total_price)
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -238,7 +250,7 @@ const ExpenseRealizationContent = ({
|
||||
<tr key={pengajuanIdx}>
|
||||
<td>{pengajuanItem.nonstock.name}</td>
|
||||
<td>{pengajuanItem.qty}</td>
|
||||
<td>{formatCurrency(pengajuanItem.total_price)}</td>
|
||||
<td>{formatCurrency(pengajuanItem.price)}</td>
|
||||
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -269,7 +281,7 @@ const ExpenseRealizationContent = ({
|
||||
let expenseGrandTotal = 0;
|
||||
|
||||
kandangExpense.realisasi?.forEach(
|
||||
(item) => (expenseGrandTotal += item.total_price)
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -300,7 +312,7 @@ const ExpenseRealizationContent = ({
|
||||
<tr key={realisasiIdx}>
|
||||
<td>{realisasiItem.nonstock.name}</td>
|
||||
<td>{realisasiItem.qty}</td>
|
||||
<td>{formatCurrency(realisasiItem.total_price)}</td>
|
||||
<td>{formatCurrency(realisasiItem.price)}</td>
|
||||
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user